Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Content Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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):

  1. composer require zenstruck/content-bundle:dev-master (no tagged install worth trusting beyond the 2013 release).
  2. Register new Zenstruck\ContentBundle\ZenstruckContentBundle() in AppKernel.
  3. Import the bundle routing if you want the built-in page controller:
    zenstruck_content:
        resource: "@ZenstruckContentBundle/Resources/config/routing.xml"
    
  4. Let Doctrine create the CTI tables: app/console doctrine:schema:update --force (or generate a migration — see Gotchas).
  5. Create a 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.

Implementation Patterns

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.

Gotchas and Tips

  • Archived means no fixes. PHP 7+ removed/changed things this code relies on (reserved class names, deprecated Symfony APIs, getMock in tests). Expect to patch before it will even boot on a semi-modern PHP.
  • CTI is join-heavy. Every 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.
  • Schema/migrations. 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.
  • Discriminator map drift. Adding a subclass but forgetting to update the discriminator map (or the bundle config that mirrors it) yields rows Doctrine can't hydrate — often surfacing as a NULL entity or a "cannot instantiate abstract class" error. Run doctrine:mapping:info and verify the map after any type addition.
  • Slug collisions. Uniqueness is easy to get wrong across types; a 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.
  • Security. The shipped content controller is public by default and the bundle ships no access control for editorial actions. Lock down admin routes explicitly; don't assume the bundle does it.
  • Caching. There's no HTTP cache or fragment caching built in. Content pages are read-heavy and CTI is slow — put a reverse proxy or at minimum a fragment cache in front of rendered blocks, and tag/invalidate by content id on save.
  • Extension points that actually work: override Twig templates under 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.
  • Debugging checklist: 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.
  • Bottom line for a new project: don't install it. Copy the three ideas that still hold up — CTI (or a type column) for heterogeneous content, a manager service owning publish/slug logic, and a template-name column validated against an allow-list — into a maintained stack (Symfony CMF, EasyAdmin, or a hand-rolled entity). The bundle itself is a 2013 reference, not a dependency.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings