sulu/article-bundle
SuluArticleBundle adds performance-optimized article management to the Sulu CMS. Articles are localized, content-rich entities that can be edited in the admin interface, organized into separate type-based lists, and rendered on Sulu-powered websites.
SuluArticleBundle is not a standalone library — it is an add-on to a Sulu application (badge says Sulu >=2.0, so pick the bundle major that matches your Sulu major, e.g. ^2.x for Sulu 2, ^3.x for Sulu 3). Treat it as "pages, but for a second content entity", and everything else follows.
Minimal setup:
composer require sulu/article-bundle
// config/bundles.php
return [
// ... Sulu core bundles first
Sulu\Bundle\ArticleBundle\SuluArticleBundle::class => ['all' => true],
];
# config/packages/sulu_article.yaml
sulu_article:
default_type: blog
types:
blog:
translation_key: sulu_article.type_blog
news:
translation_key: sulu_article.type_news
If your installed version's Configuration.php exposes a templates list per type, register your template keys there too — the bundle has changed this area across majors, so read that file once before writing YAML.
bin/console doctrine:migrations:migrate # creates the ar_* tables
bin/console cache:clear
bin/console massive:search:reindex # admin search index
Then, in the admin:
view/add/edit/delete/publish on the new article security contexts (sulu.articles.blog, sulu.articles.news). New types are invisible until permissions exist.view.Templates are XML, in the same schema as page templates, under config/templates/articles/ (typically one file per template key). Look first at an existing page template in config/templates/pages/ — article templates are 95% the same file with a different directory.
First use case to build: a blog index page (a normal Sulu page using a smart_content property pointed at the articles data source) plus the article detail rendering. That exercises the two things you will do every day: list articles and render one article.
Types are cheap: one config entry, one template folder, one security context. Use them for content that genuinely differs (blog post vs. press release vs. job ad), not for taxonomy. Taxonomy belongs in categories/tags, which articles already support and which smart content can filter on.
Sections, blocks, text_line, media_selection, snippet, single_article_selection — all the page property types are available. The <view> element decides the Twig template used for the detail page, so a template key can be routed to a completely different layout without touching PHP.
<template xmlns="http://schemas.sulu.io/template/template"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://schemas.sulu.io/template/template http://schemas.sulu.io/template/template-1.0.xsd">
<key>blog-detail</key>
<view>pages/blog-detail</view>
<controller>Sulu\Bundle\ArticleBundle\Controller\ArticleController::indexAction</controller>
<cacheLifetime>604800</cacheLifetime>
<properties>
<property name="title" type="text_line" mandatory="true">
<meta><title lang="en">Title</title></meta>
</property>
<property name="teaser" type="text_area"/>
<property name="content" type="text_editor"/>
</properties>
</template>
Prefer the bundle's website controller/route provider over writing your own resolver: it takes care of locale-aware route lookup, draft/publish state and 404s. In Twig the resolved article behaves like a page structure:
{# templates/pages/blog-detail.html.twig #}
{% extends 'base.html.twig' %}
{% block content %}
<h1>{{ article.title }}</h1>
<p>{{ article.teaser }}</p>
<div>{{ article.content|raw }}</div>
<a href="{{ sulu_content_path(article.route) }}">{{ article.title }}</a>
{% endblock %}
Use sulu_content_path() (not raw path()) so the locale/domain of the current request is preserved.
The bundle registers an articles smart-content data source. In a page template:
<property name="articles" type="smart_content">
<params>
<param name="provider" value="articles"/>
<param name="types" value="blog"/>
<param name="limit" value="10"/>
<param name="present_as" value="list"/>
</params>
</property>
{% for article in content.articles %}
<a href="{{ sulu_content_path(article.route) }}">{{ article.title }}</a>
<time datetime="{{ article.authored|date('c') }}">{{ article.authored|date('d.m.Y') }}</time>
{% endfor %}
Always set types. With no type filter you silently mix every article type in the project into the list — usually discovered only after a client adds a second type.
Use single_article_selection (or article_selection for a curated set) inside templates. Sulu resolves these to view objects automatically when you access the property from content/the structure bridge, so {% for a in content.related %} works without extra hydration. This is the standard way to build "related posts" or a manually curated homepage teaser strip.
For bulk imports or data syncs, go through sulu_article.article_manager rather than hydrating Doctrine entities — the manager handles structure mapping, resource locators, routes and versioning:
use Sulu\Bundle\ArticleBundle\Article\ArticleManagerInterface;
final class BlogImport
{
public function __construct(private ArticleManagerInterface $articleManager) {}
public function import(array $row): string
{
$uuid = $this->articleManager->save(
[
'template' => 'blog-detail',
'title' => $row['title'],
'teaser' => $row['teaser'],
'content' => $row['body'],
],
null, // null = create
'en',
['type' => 'blog']
);
$this->articleManager->publish($uuid, ['locale' => 'en']);
return $uuid;
}
}
Exact signatures have shifted between majors (save/publish options arrays in particular) — read ArticleManagerInterface in your installed version before writing the importer, and wrap it in a thin facade so an upgrade only touches one class.
Articles are localized per dimension, exactly like pages: content, route, and publish state are per locale. For translations, write the target locale with the same UUID through the manager (or use locale-copy helpers if your version provides them). Do not copy the route string from another locale — the resource locator must be regenerated per locale to stay unique.
The bundle ships admin UI, not a JSON API. For a headless front end, write a small controller and use ArticleRepository::findByUuids($uuids, $locale) / findByType() to batch-load, then serialize ArticleViewObject data yourself. Batching by UUID is the pattern that scales; per-item findByUuid in a loop is the classic N+1.
The template "x" was not found. Run cache:clear (or sulu:build) after any template change.template value stored on the article must be a template that is valid for its type. Renaming a template key without a migration leaves articles pointing at a non-existent template — they still load in the admin list but blow up on edit./blog/foo and an article at /blog/foo can coexist in the routing table; one wins at runtime. If you have a page-based blog archive and article URLs under the same prefix, verify actual resolution order early.setHint/eager loading, and cache the rendered fragment instead of the query.sulu_article.article_manager / sulu_article.article_repository, adding your own content types, and overriding the view/controller in template XML. Prefer the template controller override for one-off pages rather than replacing the bundle controller.massive:search:reindex, or articles exist but cannot be found in the admin search. Similarly, test suites need the index built after fixtures.ArticleManagerInterface, preserving UUIDs in your own mapping table so redirects and cross-references survive.How can I help you explore Laravel packages today?