zenstruck/content-bundle
Zenstruck ContentBundle is a Symfony bundle for storing, editing, and rendering reusable content blocks from the database. Define named content, manage it in your admin, then render it anywhere in Twig—keeping editable copy out of your templates.
Reality check first: this bundle is archived, last released in December 2013, and targets Symfony 2.x with Doctrine 2's Class Table Inheritance. There is no Symfony 3/4/5/6 support and no PHP 7+ compatibility work. Day-to-day "leverage" in a modern project is essentially nil — treat it as a reference implementation of a CTI-based CMS, or as a legacy dependency you are maintaining, not as something you add to a new application. If you are actually installing it, pin the exact commit and vendor it.
Minimal steps to get it running (legacy Symfony 2 app only):
composer require zenstruck/content-bundle:dev-master (no tagged install worth trusting beyond the 2013 release).new Zenstruck\ContentBundle\ZenstruckContentBundle() in AppKernel.zenstruck_content:
resource: "@ZenstruckContentBundle/Resources/config/routing.xml"
app/console doctrine:schema:update --force (or generate a migration — see Gotchas).Page record (title, slug, body, published = true) via whatever admin/form you wire up, then hit /{slug}.Where to look first:
Entity/Content.php — the CTI base entity. This is the whole design: shared columns (title, slug, body, template, published/publishAt, timestamps) live here, subclasses add their own.Entity/Page.php and Entity/Block.php — the shipped concrete types; Block is the reusable-fragment type intended to be embedded in pages.Manager/ContentManager.php (service id typically zenstruck_content.manager) — the façade you'll call from controllers and templates.Resources/config/doctrine/*.orm.xml — the actual mapping; this is where the discriminator map and JOINED inheritance are declared.DependencyInjection/Configuration.php — what's configurable (enabled content types, templates).First use case: a small marketing site where editors create Page records in an admin, the bundle's controller resolves /{slug} → published Page → renders the Twig template named onthe record. That path (slug → manager lookup → template render) is the 20% of the bundle you'll use 80% of the time.
CTI as the extension mechanism. The intended workflow for a new content type is: subclass Content, add the Doctrine mapping (annotation or XML) with the parent's discriminator map updated, then register the class in bundle config so the manager and admin know about it. That's the "plugin" story — there is no separate type registry beyond the discriminator map plus config.
Manager-centric access. Don't reach for repositories directly; go through the content manager service so publishing filters, slug resolution, and type-awareness are applied consistently:
$page = $this->get('zenstruck_content.manager')->findPublishedBySlug($slug);
if (!$page) { throw $this->createNotFoundException(); }
Wrap that in your own thin controller/action rather than relying on the shipped controller, so you control caching and 404 behaviour.
Publishing workflow. published plus a publishAt timestamp is the standard pattern: content is visible only when published = true and publishAt <= now. Enforce this in one place (the manager or a query scope) so a stray repository call can't leak drafts.
Template-per-record. The template column on Content lets editors/developers pick the Twig template per page, with a configured fallback. This is genuinely useful and the pattern is worth copying even if you don't copy the bundle: store the template name on the record, validate it against an allow-list, and render through a single entry point.
Blocks embedded in pages. The Block type exists so fragments (promos, footers, reusable copy) can be authored once and rendered inside page templates — typically via a Twig function/extension that looks a block up by slug and renders it. Watch for the N+1 this creates; batch-load blocks or cache rendered output.
Admin integration. The bundle predates EasyAdmin and was typically paired with Sonata Admin: one Admin class per content subclass, sharing a base admin. If you're maintaining a legacy install, keep that pattern; if you're porting, model it as one CRUD controller per type (EasyAdmin) or a single controller that switches form type by discriminator.
Slug generation. In this era slugs were usually set manually or generated in a prePersist/preUpdate listener from the title, with uniqueness scoped to the content type. If you add types, decide early whether slugs are globally unique (simplest, matches a single /{slug} route) or per-type.
If you're porting the idea rather than the bundle: the CTI design translates cleanly to a modern Content entity using #[ORM\InheritanceType('JOINED')] with a discriminator map, or to a single content table with a type column if you'd rather avoid the joins. The manager + template-on-record + published/publishAt triad is the part worth keeping.
getMock in tests). Expect to patch before it will even boot on a semi-modern PHP.Content query joins the base table to every subclass table touched. Keep the hierarchy shallow, select only the types you need (WHERE c INSTANCE OF Page or query the subclass repository), and never build list pages off an unfiltered base repository.doctrine:schema:update will happily create the CTI tables, but hand-written migrations must create the base table and each subclass table with the shared primary key/FK. A partial migration here produces confusing "column not found" errors at runtime.NULL entity or a "cannot instantiate abstract class" error. Run doctrine:mapping:info and verify the map after any type addition.Block slugged footer can shadow a Page slugged footer depending on the lookup query. Scope slug lookups by type or enforce global uniqueness at the DB level.publishAt and timezones. The timestamp comparison is naive; if your app stores UTC but the server is local (or vice versa), scheduled content appears early or late. Normalise to a single timezone and compare in the query.app/Resources/ZenstruckContentBundle/views/, decorate or subclass the manager service, and add Doctrine event listeners (prePersist/preUpdate) for slugs and timestamps. Avoid editing the bundle in place — vendor patches disappear on the next composer update.doctrine:mapping:info for the discriminator map, app/console doctrine:query:sql to confirm the subclass row exists alongside the base row, and check that published/publishAt are what you think they are before blaming the controller.How can I help you explore Laravel packages today?