Search by

mulertech / blog-bundle

mulertech

Symfony bundle for a Markdown blog: shared article model, categories and tags, admin, public pages, search, scheduled publication, Atom feed, social sharing and SEO, with one article per language

Package info

github.com/mulertech/blog-bundle

Type:symfony-bundle

pkg:composer/mulertech/blog-bundle

Statistics

Installs: 51

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.8.0 2026-11-10 16:09 UTC

This package is auto-updated.

Last update: 2026-11-10 16:11:48 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub PHPStan Action Status Total Downloads Test Coverage

Symfony bundle for a Markdown blog: shared article model, admin, public pages, search, social sharing and SEO, with one article per language.

Requirements

  • PHP 8.4+ with the GD extension, which converts covers and attached images to WebP and lets mulertech/seo-bundle derive the sharing images, the exif extension, which reads how a phone photo is to be turned upright, and the fileinfo extension, which tells the type of an uploaded file (Windows builds of PHP leave it disabled)
  • Symfony 8.0+, with AssetMapper serving the admin script
  • Doctrine ORM 3; the queries are portable, the sites run PostgreSQL

Installation

From a Symfony application with Doctrine, Twig and AssetMapper, in this order. Each step links to the section detailing it.

  1. Require the bundle. Symfony Flex registers it, along with mulertech/file-bundle, mulertech/seo-bundle and twig/extra-bundle; without Flex, add them to config/bundles.php:

    composer require mulertech/blog-bundle

    The cache:clear Composer runs afterwards fails, naming mulertech_file.storage_directory: the bundles refuse to boot without their configuration, which steps 2 to 5 provide.

  2. Set up mulertech/file-bundle, which stores the featured images and attached files: a file entity extending MulerTech\FileBundle\Entity\AbstractFile, and its storage_directory and file_class, as its README says. The logged-in user who writes articles must implement MulerTech\FileBundle\Model\FileUploaderInterface. In production, storage_directory must be a persistent volume, included in the backups: inside the container alone, it is emptied by every deployment, and the articles lose their images.

  3. Set up mulertech/seo-bundle, which declares the pages to search engines and derives the sharing images: its configuration, a SeoCompanyInfoProviderInterface service and its routes, as its README says. Its default_image is shared for an article without featured image.

  4. Create the blog post entity and point FileInterface at the file entity (1. The blog post entity). For categories and tags, create their entities too (Categories and tags).

  5. Name the entity in config/packages/mulertech_blog.yaml (Configuration).

  6. Import the public and admin routes (Routes), and keep the admin prefix behind the site's firewall (5. Administration).

  7. Generate and run the migration: bin/console doctrine:migrations:diff, then doctrine:migrations:migrate. It creates the blog's table, its join table to the files, the unique constraint on (slug, locale) and the index on translation_group.

  8. Make Tailwind scan the bundle's templates and enable @tailwindcss/typography (3. Templates).

  9. Check that the layouts fill the blocks the pages use: title, seo_meta and body for public_layout; title and body for admin_layout, which also shows the flash messages (Configuration). A layout writing <meta name="description"> itself, in a block of its own, needs blog/index.html.twig and blog/show.html.twig replaced to fill that block with seo.description (3. Templates); otherwise every page of the blog carries the description of the home page.

/blog then lists the articles, /admin/blog creates them.

Configuration

# config/packages/mulertech_blog.yaml
mulertech_blog:
    # The site's entity extending AbstractBlogPost (see below). Required.
    post_class: App\Entity\BlogPost
    # Template the public pages extend; they fill its `title`, `seo_meta` and `body`
    # blocks. Defaults to `base.html.twig`.
    public_layout: base.html.twig
    # Template the admin pages extend; they fill its `title` and `body` blocks and leave
    # flash messages (`success`, `error`) to it. Defaults to `base.html.twig`.
    admin_layout: admin/base.html.twig
    # Role every admin action checks. Defaults to ROLE_ADMIN.
    admin_role: ROLE_ADMIN
    # Form of the admin, to add the site's own fields (see below).
    post_form_type: App\Form\BlogPostType
    # Languages the articles are written in. Defaults to the site's default locale; with
    # more than one, the admin asks for the language of each article.
    locales: [fr, en]
    # The site's category and tag entities (see below). Without them, the blog has no
    # categories nor tags: no column, no field, no filter.
    category_class: App\Entity\BlogCategory
    tag_class: App\Entity\BlogTag
    # Forms of the categories and tags in the admin, to add the site's own fields: a type
    # whose getParent() returns MulerTech\BlogBundle\Form\BlogTermType.
    category_form_type: App\Form\BlogCategoryType
    # An excerpt per article, and the box marking an article as generated by an AI.
    # Both default to true; false leaves no column and no admin field, for a blog whose
    # articles are all written by hand without a summary.
    excerpt: false
    ai_label: false

Routes

Import the routes under the prefix of your choice:

# config/routes/mulertech_blog.yaml
mulertech_blog:
    resource: '@MulerTechBlogBundle/config/routes.yaml'
    prefix: /blog            # or /{_locale}/blog on a multilingual site
    trailing_slash_on_root: false

trailing_slash_on_root: false keeps the list at /blog rather than /blog/.

The admin routes go under the site's admin prefix:

# config/routes/mulertech_blog.yaml
mulertech_blog_admin:
    resource: '@MulerTechBlogBundle/config/admin_routes.yaml'
    prefix: /admin/blog
    trailing_slash_on_root: false
Route Path Page
mulertech_blog_index /blog The list, ?q= to search, ?page= from 2 on
mulertech_blog_show /blog/{slug} An article
mulertech_blog_file /blog/{slug}/files/{id} The featured image or an attached file of a published article
mulertech_blog_feed /blog/feed.xml The Atom feed of the language

The list also takes ?category=<slug> and ?tag=<slug>. A slug unknown in the language, or a filter on a site without categories or tags, answers 404.

The admin routes manage the articles under the admin prefix, the categories under /categories and the tags under /tags of it. On a site without categories or tags, their pages answer 404 naming the key to set.

The pages read the articles of the request's locale: a single-language site serves its default locale, a multilingual one the {_locale} of its prefix. A draft, an article of another language, a page number that is not a positive integer or lies beyond the last page answer 404.

Files are served only through the published article they belong to, so the private storage of mulertech/file-bundle stays out of reach whatever id is typed. Images are shown inline, other files downloaded under their original name, both cached a day by browsers and proxies, with an ETag. A file the article points at but the disk no longer holds fails with a 500 naming the path.

Usage

1. The blog post entity

The bundle maps the fields every blog shares in AbstractBlogPost, a Doctrine mapped superclass. The site declares the entity, which owns the table and adds whatever only that site needs:

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use MulerTech\BlogBundle\Entity\AbstractBlogPost;

#[ORM\Entity]
#[ORM\Table(name: 'blog_posts')]
class BlogPost extends AbstractBlogPost
{
    // Fields of this site only: category, tags, author...

    // Who writes, shown with the article by search engines: an author, the company.
    public function getSeoAuthorName(): string
    {
        return 'MulerTech';
    }
}

A constructor of its own calls parent::__construct(): it creates the collection of attached files and the translation group.

The featured image and the attached files target FileInterface from mulertech/file-bundle. Point it at the site's file entity:

# config/packages/doctrine.yaml
doctrine:
    orm:
        resolve_target_entities:
            MulerTech\FileBundle\Model\FileInterface: App\Entity\File
Field Column Notes
id id Integer
title, slug, content same content is Markdown
metaDescription meta_description
excerpt excerpt Plain text, shown on the article's card; the description for search engines when the meta description is empty. Not mapped under excerpt: false
aiGenerated ai_generated Shows the label of the European Union on the article (AI Act, article 50 §4). Defaults to false, in SQL too, so the articles a table already holds are marked as written by hand: mark a generated one in the admin. Not mapped under ai_label: false: the article is then never marked
featuredImage featured_image_id
files join table <entity>_file blog_post_file for BlogPost under the underscore naming strategy
isPublished, publishedAt is_published, published_at publish() keeps the date of first publication, so unpublishing then publishing again never moves an article to the top of the list. A date still to come is a scheduled publication: schedule() sets it, and the article stays out of every public page until then
readingTime, viewCount reading_time, view_count
createdAt, updatedAt created_at, updated_at Set on persist and on update
locale locale The language the article is written in
translationGroup translation_group Shared by the versions of one article in several languages; setTranslationOf() joins the group of another article
category category_id Mapped when the site names category_class, nullable
tags join table <entity>_<tag entity> Mapped when the site names tag_class: blog_post_blog_tag for BlogPost and BlogTag

Column names are fixed rather than left to the naming strategy, so an existing table keeps its columns.

The bundle adds to the entity's table a unique constraint on (slug, locale) and an index on translation_group. A mapped superclass cannot carry them: Doctrine ignores table constraints declared there.

Categories and tags

Both are optional. A site wanting them declares an entity for each, extending the bundle's, and names them in the configuration:

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use MulerTech\BlogBundle\Entity\AbstractBlogCategory;

#[ORM\Entity]
#[ORM\Table(name: 'blog_categories')]
class BlogCategory extends AbstractBlogCategory
{
    // Fields of this site only: a colour, a position...
}

AbstractBlogTag is extended the same way. Both carry name, slug, locale and translation_group; a category also has a description, shown above its articles. The bundle adds a unique constraint on (slug, locale) and an index on translation_group to each table, and maps category and tags on the article: a mapped superclass cannot target classes it does not know, so these relations exist only once the classes are named. Without them, getCategory() stays null and getTags() empty, whatever is set.

An article has at most one category, which may be left empty; a site wanting it required redefines the category field in its form type with required: true. A category still holding articles cannot be deleted from the admin; a tag can, and leaves its articles.

A term belongs to one language, like an article: an article is filed under terms of its own language, and the admin refuses one of another language, naming both. The versions of a term in other languages share its translationGroup.

Multilingual model

One row is one article in one language. The versions of an article in several languages are separate rows sharing a translationGroup, each with its own slug, publication date and view count. A single-language site never sees more than a column filled on its own.

2. Services

Service Role
BlogPostRepository The queries of the blog, on the entity named by post_class. Every list holds published articles of one language
SlugGenerator generate($post): the slug from the title, unique in the article's language (bienvenue, then bienvenue-2…)
BlogPostPublisher publish($post) and unpublish($post), saved at once
ViewCounter count($post): one view per article and per session, incremented in the database so simultaneous readers lose none

BlogPostRepository:

Method Returns
findPublishedBySlug($slug, $locale) The article, or null when it is unknown or a draft
paginatePublished($locale, $page, $search = null, $size = 12) A Page of articles, newest first, through Doctrine's Paginator
findRecent($locale, $limit = 5) The latest published
findPopular($locale, $limit = 5) The most viewed
findPrevious($post), findNext($post) The neighbours by publication date, in the same language
findTranslations($post) The published versions in the other languages
isSlugTaken($slug, $locale, $exceptId = null) Whether the language already has the slug, drafts included

Page carries items, number, size, totalItems, pageCount, hasPrevious(), hasNext(), and exists(), false beyond the last page, whose address should answer 404.

The search keeps the articles holding every word typed, each in the title or the content, ignoring case. A word finds the longer ones containing it (hébergement finds hébergements), and the articles holding all the words in their title come first. Accents count: heberger does not find héberger.

The reading time is derived from the content each time it changes, at 200 words a minute; the address of a link or an image is not counted.

3. Templates

The pages need twig/extra-bundle (installed with the bundle), which provides the markdown_to_html filter. Their markup uses Tailwind CSS, with the default palette and the prose classes of @tailwindcss/typography for the article.

Tailwind only generates the classes it reads in the files it scans, and it does not scan vendor/. Without this line, the pages render unstyled and nothing says why:

/* assets/styles/app.css */
@source "../../vendor/mulertech/blog-bundle/templates";

Each part of a page is a template of its own, so a site replaces only what differs from its own design, in templates/bundles/MulerTechBlogBundle/:

Template Part
blog/index.html.twig The list page; its mulertech_blog_header block holds the heading
blog/show.html.twig The article page; its after_article block, empty, sits between the article and the navigation
blog/_search.html.twig The search form
blog/_filter.html.twig The category or tag the list is filtered by, with the category's description
blog/_card.html.twig An article in the list: square thumbnail cut from the centre of the featured image, category, title, date, reading time, views, excerpt, "Lire la suite"
blog/_sidebar.html.twig The categories holding articles, with their count, then the recent and popular articles
blog/_pagination.html.twig Previous and next pages
blog/_share.html.twig Sharing on LinkedIn, X and Facebook
blog/_navigation.html.twig Previous article, back to the list, next article
blog/feed.xml.twig The Atom feed

The article page shows its category and tags, each a link to the filtered list, and the label of the European Union when aiGenerated is set. The label is an <img> served by the site's AssetMapper, @mulertech/blog-bundle/images/eu-ai-label-ai-generated.svg, rather than inline: the official file declares generic classes, .cls-1 and .cls-2, which would collide with the site's stylesheet.

What a site shows after an article, a call to action or a newsletter form, goes in the after_article block. The admin preview puts its publication panel in that block, in place of the site's content: what is meant for readers has no use to the one reviewing. A site replacing the whole body block keeps the preview's panel by declaring the block where the panel belongs:

{# templates/bundles/MulerTechBlogBundle/blog/show.html.twig #}
{% block after_article %}
    {% include 'blog/_newsletter.html.twig' %}
{% endblock %}

A replaced template addresses the article's files with mulertech_blog_file_url(post, file) rather than path('mulertech_blog_file', …): the function gives the public address, and during an admin preview the admin one. The public route answers only for a published article, and the admin is often signed in behind a firewall the public pages do not share, so a template building the public address shows a draft's images broken in the preview.

Both pages replace the layout's seo_meta block with their own meta tags (see below): whether a layout leaves that block empty or includes the seo-bundle template there, the page carries its tags exactly once.

The seo-bundle template writes no <meta name="description">: layouts write it themselves, each in a block named its own way, which the bundle cannot know of. A site whose layout does so fills that block in its replacement of blog/index.html.twig and blog/show.html.twig; seo.description holds the description, null for an article without meta description:

{# templates/bundles/MulerTechBlogBundle/blog/show.html.twig #}
{% block description %}{{ seo.description ?? parent() }}{% endblock %}

4. Labels

Every visible text goes through the MulerTechBlogBundle translation domain. The bundle ships French. A site rewords a label by redefining its key in its own translations/MulerTechBlogBundle.fr.yaml; the site's translations take precedence over the bundle's.

5. Administration

The admin lists every article, drafts and every language included, creates, edits, publishes, unpublishes and deletes them, and manages their featured image and attached files.

Every admin action checks admin_role itself, whatever the firewall says, and every change requires a CSRF token. Keep the admin prefix behind the site's firewall all the same, so that a visitor is sent to the login page rather than refused.

Files are stored through mulertech/file-bundle under a name starting with the article's id, the same on every site whatever its directory strategy: blog/images/image-12-<unique>.webp for a cover, blog/files/file-12-<name>-<unique>.pdf for an attached file. A file on disk is matched to its article at a glance, whoever uploaded it. A file belongs to one article: removing it from the article, or deleting the article, deletes it from the storage.

The uploader is the logged-in user, which must implement MulerTech\FileBundle\Model\FileUploaderInterface. A site storing files without a session, an API fed by an automation or an import, passes the uploader itself: BlogMediaManager::setCover($post, $image, $author) and attach($post, $file, $author). The uploader is not stored on the file by file-bundle: it only feeds the upload log, and the site's own file entity when it keeps one.

Feature Behaviour
Slug Built from the title when left blank; one typed by hand must be free in the article's language
Featured image Stored at exactly 1200×675 px (16:9) as WebP. Another size opens a cropper in the browser; a cover of another size that reaches the server anyway is refused, naming both sizes
Attached files Listed with an "insert" button that writes ![name](blog-file:12) for an image, [name](blog-file:12) for a document, where the cursor stands in the content
Attached images A JPEG or PNG is stored as WebP, turned upright from its EXIF orientation and brought down to 1600 px wide when wider; a WebP is only brought down. A GIF keeps its animation and an SVG stays a vector: both are stored as sent, like documents. An image that cannot be decoded is refused, naming the file
Publication "Publier" and "Dépublier" buttons; the date of first publication is kept
Scheduled publication "Programmer" with a date and time to come, in the server's time zone, for an article never published or still waiting: it appears on every public page at that date, without any task to run. "Publier maintenant" brings a scheduled article forward. An article published before keeps its date and cannot be scheduled
Categories and tags A category to choose and tags to tick, on a site naming their classes; their own pages list, create, edit and delete them, with the number of articles each holds
Excerpt and AI label An excerpt field, and a box marking the article as generated by an AI, which shows the label of the European Union on its page; each one absent when turned off
Preview "Aperçu" opens the article in the site's public template, drafts included, from its last saved version, under a banner saying so. Its route sits under the admin prefix, so only the admin reaches it; it counts no view and sends X-Robots-Tag: noindex

The content refers to an attached file as blog-file:<id> rather than by its address, which carries the slug and the language prefix: correcting a slug would otherwise break every image of the article. The mulertech_blog_content Twig filter turns these references into addresses; the article template applies it before markdown_to_html.

The admin script, @mulertech/blog-bundle/admin.js, is served by the site's AssetMapper, which the bundle registers it with. It has no inline code, so a CSP allowing 'self' scripts lets it through, and its listeners are delegated to the document, so it keeps working on pages replaced by Turbo.

To add the site's own fields, give a form type whose parent is the bundle's:

namespace App\Form;

use MulerTech\BlogBundle\Form\BlogPostType as BaseBlogPostType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;

final class BlogPostType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('subtitle', TextareaType::class, ['required' => false]);
    }

    public function getParent(): string
    {
        return BaseBlogPostType::class;
    }

    // Named after the class, the prefix would be `blog_post`, the bundle's own: the form
    // would then fail to render, its block names appearing twice.
    public function getBlockPrefix(): string
    {
        return 'article';
    }
}

The fields it adds are rendered after the bundle's ones; replace admin/_form.html.twig to lay them out otherwise.

The admin form carries its own form theme, @MulerTechBlog/form/theme.html.twig, applied over the site's: the pages look the same on a site declaring no global form theme, where Symfony's default one would leave the fields without a border. The fields a site adds take the same look.

Template Part
admin/index.html.twig The list
admin/edit.html.twig Creation and edition
admin/_actions.html.twig The bar above the form: status, preview, publication, delete
admin/_publication.html.twig Publish and unpublish, in that bar and in the publication panel of the preview
admin/_schedule.html.twig The publication date, on a line of its own below those buttons, while the article can be scheduled
admin/preview.html.twig The preview, in the public template of the article
admin/_form.html.twig The form, with the cover cropper
admin/_files.html.twig The attached files
form/theme.html.twig The form theme of the admin form

A button is added or restyled without copying the whole bar, by extending the bundle's template and overriding one block: preview_link and view_link in _actions.html.twig, publish in _publication.html.twig. A button added to publish appears both above the form and in the publication panel of the preview, where the review ends:

{# templates/bundles/MulerTechBlogBundle/admin/_publication.html.twig #}
{% extends '@!MulerTechBlog/admin/_publication.html.twig' %}

{% block publish %}
    <form method="post" action="{{ path('app_blog_publish_reviewed', {id: post.id}) }}">
        <input type="hidden" name="_token" value="{{ csrf_token('publish_reviewed' ~ post.id) }}">
        <button type="submit">Publish after review</button>
    </form>
    {{ parent() }}
{% endblock %}

6. Search engines and social platforms

The blog relies on mulertech/seo-bundle, configured and with its routes imported as its README says: the sitemap, llms.txt and the social cards are served by its routes.

Page or file What the blog declares
List Title and description (the blog.title and blog.meta.description labels, to reword in the site's translations). A search or a filter points its canonical at the list, so no query is indexed as a page of its own
Article Title, meta description (the excerpt when it is empty), og:type article, publication and modification dates, author, and an hreflang link per published version in another language
Article image The 1200×630 JPEG card seo-bundle derives from the featured image, under the social card type blog; without featured image, the site's default_image
Structured data A schema.org BlogPosting (headline, author, dates, description)
Sitemap The list of each language, then every published article, dated by its last change
llms.txt A section listing the published articles with their meta description
Feed /blog/feed.xml, the 20 latest articles of the language with their excerpt, announced by a <link rel="alternate"> on the list and on each article

A scheduled article enters all of them on its date: the queries read the clock, nothing has to run.

With mulertech/csp-bundle installed, the structured data script carries the nonce of its main handle; without it, no nonce, and the site's policy is its own to adapt.

A site keeping a social card provider of its own under the type blog makes seo-bundle refuse both, naming them: remove the site's one.

Testing

./vendor/bin/mtdocker test-ai

License

MIT. See LICENSE.