Blog module
Module id: blog. Dependencies: none.
What it adds:
- A sidebar tab for Articles (editor-facing, same level as Pages — no admin-only gate).
- Scheduled publishing, per-locale translations, free-form tags, a featured image and author byline per article.
- Theme helpers for listing / fetching / relating articles.
- Public routes for a blog index, single articles, tag archives, and an RSS feed.
Blog is independent of the catalog — a content site can run a blog without enabling any e-commerce module. Featured images reuse the per-website Media library, so there's no hard dependency there either.
What's per-website
Nothing on a settings entity. All blog data lives on the domain
entities (Article, ArticleTag), each scoped to a Website by
Doctrine's WebsiteFilter. There is no "blog settings" panel — what
you'd configure is per-article, edited where you create it.
Blog → Articles
Each article carries:
| Field | Notes / example |
|---|---|
| Title | "Five aftercare tips for fresh brows" |
| Slug | URL slug for /blog/{slug}, auto-derived from title if left blank. |
| Excerpt | Short summary (≤500 chars) for listing cards + the default meta description. |
| Body | Rich text (TipTap) for the article page. |
| Featured image | Reuses the per-website Media library; cover for cards + OG tags. |
| Author | Free-text byline (display only — not a user account). |
| Published? | Editor intent toggle. Off → never public. |
| Publish date | Go-live timestamp. Future date = scheduled (hidden until it passes). |
| Tags | Free-form labels. Drives /blog/tag/{slug} archives + related articles. |
| Translations | Per-locale title / excerpt / body — see multi-locale. |
| SEO override | Optional title / meta-description / og-image override. |
Visibility rule
An article is visible to the public only when Published? is on AND
its publish date has passed. That's the whole rule, and it's the same
in the theme helpers, the public routes, and the RSS feed:
- Draft (
Published?off) → 404 on the public site, visible in admin. - Scheduled (
Published?on, publish date in the future) → 404 until the date passes, shown as Scheduled in the admin list. - Live (
Published?on, publish date in the past) → public.
A freshly-created article defaults its publish date to "now", so the common case is one toggle.
Realistic example
A salon publishing an aftercare post:
Title: Vijf aftercare-tips voor verse wenkbrauwen
Slug: aftercare-tips-wenkbrauwen
Excerpt: Wat je de eerste week wel en niet moet doen.
Author: Team CeeMS
Published: on
Publish date: 2026-06-10 09:00
Tags: [aftercare, wenkbrauwen]
Featured image: aftercare-hero.jpg
Published: on
In the theme:
{% set a = cms_article('aftercare-tips-wenkbrauwen') %}
{% if a %}
<article>
<h1>{{ a.title }}</h1>
<p class="meta">
<time datetime="{{ a.publishedAt|date('c') }}">{{ a.publishedAt|date('j M Y') }}</time>
{% if a.author %} · {{ a.author }}{% endif %}
</p>
{% if a.featuredImage %}<img src="{{ a.featuredImage }}" alt="{{ a.title }}">{% endif %}
<div class="prose">{{ a.body|raw }}</div>
</article>
{% endif %}
Blog → Tags
Flat (no hierarchy), per-website. Created on the fly from the article
editor's tag chip-input — type a name and press Enter. Each tag has a
public archive at /blog/tag/{slug} listing every visible article that
carries it, and tags drive the "Read next" related-articles block.
Theme helpers (when blog is enabled)
Full list in ../../twig-helpers.md#blog-module-blog. TL;DR:
{# Latest articles on the homepage. #}
{% for a in cms_articles({limit: 6}) %}
<article>
<a href="{{ path('cms_article_show', { slug: a.slug }) }}">{{ a.title }}</a>
<p>{{ a.excerpt }}</p>
</article>
{% endfor %}
{# A sidebar "latest posts" widget. #}
{% for a in cms_recent_articles(5) %}
<a href="{{ path('cms_article_show', { slug: a.slug }) }}">{{ a.title }}</a>
{% endfor %}
{# On an article page: related posts (shared tags). #}
{% for a in cms_related_articles(article, 3) %}
<a href="{{ path('cms_article_show', { slug: a.slug }) }}">{{ a.title }}</a>
{% endfor %}
{# Every visible article carrying a tag. #}
{% for a in cms_articles_by_tag('aftercare', {limit: 12}) %}…{% endfor %}
All helpers return [] / null when the blog module is off, so a
shared theme can call them unconditionally and just render nothing on
non-blog sites.
Public-side templates (themes can override)
Page templates rendered by the blog routes — copy the file under
templates/ in the theme repo to override:
| Default | Override at |
|---|---|
@GsidCmsCore/pages/articles.html.twig |
@website/pages/articles.html.twig |
@GsidCmsCore/pages/article.html.twig |
@website/pages/article.html.twig |
@GsidCmsCore/pages/articles_tag.html.twig |
@website/pages/articles_tag.html.twig |
@GsidCmsCore/blog/feed.xml.twig |
(feed; override rarely needed) |
Public routes
The blog module mounts the following routes when enabled:
| Route | Path | Notes |
|---|---|---|
cms_articles_index |
/blog |
Paginated index (12/page). |
cms_articles_feed |
/blog/feed.xml |
RSS 2.0 feed of the 20 latest articles. |
cms_article_tag |
/blog/tag/{slug} |
Paginated tag archive. |
cms_article_show |
/blog/{slug} |
Single article. |
The feed and tag routes sit on distinct prefixes and are matched before
the generic /blog/{slug}, so a post can't shadow them.
These routes coexist with theme-defined pages — if you have a Page
with slug blog, the page wins (the page renderer matches first).
Use that if you want a CMS-edited landing on top of the article list.
Things this module does NOT do
- No comments. No commenting system, moderation, or spam handling.
- No categories. Tags are the only taxonomy; there's no hierarchical category tree (use catalog if you need that shape).
- No author accounts. "Author" is a free-text byline, not a link to a CMS user or an author archive page.
- No revisions / drafts-alongside-published. One row per article; editing a live article edits the live article. Use the publish toggle to take it offline while you rework it.