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.