mulertech / blog-bundle
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
Requires
- php: ^8.4
- ext-exif: *
- ext-fileinfo: *
- ext-gd: *
- doctrine/dbal: ^4.0
- doctrine/orm: ^3.0
- league/commonmark: ^2.0
- mulertech/file-bundle: ^1.2
- mulertech/seo-bundle: ^1.7
- symfony/asset: ^8.0
- symfony/asset-mapper: ^8.0
- symfony/clock: ^8.0
- symfony/config: ^8.0
- symfony/dependency-injection: ^8.0
- symfony/form: ^8.0
- symfony/http-foundation: ^8.0
- symfony/http-kernel: ^8.0
- symfony/mime: ^8.0
- symfony/routing: ^8.0
- symfony/security-core: ^8.0
- symfony/security-csrf: ^8.0
- symfony/string: ^8.0
- symfony/translation: ^8.0
- symfony/translation-contracts: ^3.0
- symfony/twig-bundle: ^8.0
- symfony/uid: ^8.0
- symfony/validator: ^8.0
- twig/extra-bundle: ^3.0
- twig/markdown-extra: ^3.0
- twig/twig: ^3.0
Requires (Dev)
- ext-pdo_sqlite: *
- doctrine/doctrine-bundle: ^3.3
- friendsofphp/php-cs-fixer: ^3.0
- mulertech/csp-bundle: ^3.2
- mulertech/docker-dev: ^3.2
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
- roave/security-advisories: dev-latest
- symfony/browser-kit: ^8.0
- symfony/css-selector: ^8.0
- symfony/dom-crawler: ^8.0
- symfony/framework-bundle: ^8.0
- symfony/security-bundle: ^8.0
- symfony/yaml: ^8.0
Suggests
- mulertech/csp-bundle: Adds its CSP nonce to the structured data script of the article page
Provides
None
Conflicts
None
Replaces
None
README
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-bundlederive 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.
-
Require the bundle. Symfony Flex registers it, along with
mulertech/file-bundle,mulertech/seo-bundleandtwig/extra-bundle; without Flex, add them toconfig/bundles.php:composer require mulertech/blog-bundle
The
cache:clearComposer runs afterwards fails, namingmulertech_file.storage_directory: the bundles refuse to boot without their configuration, which steps 2 to 5 provide. -
Set up
mulertech/file-bundle, which stores the featured images and attached files: a file entity extendingMulerTech\FileBundle\Entity\AbstractFile, and itsstorage_directoryandfile_class, as its README says. The logged-in user who writes articles must implementMulerTech\FileBundle\Model\FileUploaderInterface. In production,storage_directorymust be a persistent volume, included in the backups: inside the container alone, it is emptied by every deployment, and the articles lose their images. -
Set up
mulertech/seo-bundle, which declares the pages to search engines and derives the sharing images: its configuration, aSeoCompanyInfoProviderInterfaceservice and its routes, as its README says. Itsdefault_imageis shared for an article without featured image. -
Create the blog post entity and point
FileInterfaceat the file entity (1. The blog post entity). For categories and tags, create their entities too (Categories and tags). -
Name the entity in
config/packages/mulertech_blog.yaml(Configuration). -
Import the public and admin routes (Routes), and keep the admin prefix behind the site's firewall (5. Administration).
-
Generate and run the migration:
bin/console doctrine:migrations:diff, thendoctrine:migrations:migrate. It creates the blog's table, its join table to the files, the unique constraint on(slug, locale)and the index ontranslation_group. -
Make Tailwind scan the bundle's templates and enable
@tailwindcss/typography(3. Templates). -
Check that the layouts fill the blocks the pages use:
title,seo_metaandbodyforpublic_layout;titleandbodyforadmin_layout, which also shows the flash messages (Configuration). A layout writing<meta name="description">itself, in a block of its own, needsblog/index.html.twigandblog/show.html.twigreplaced to fill that block withseo.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  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.