klehm / content-blocks
Modular page builder for Symfony — entities, admin UI, form integration.
Requires
- php: >=8.2
- doctrine/orm: ^2.12 || ^3.0
- symfony/form: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/security-csrf: ^6.4 || ^7.0 || ^8.0
- symfony/stimulus-bundle: ^2.0
- symfony/twig-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/ux-live-component: ^2.0
- symfony/ux-twig-component: ^2.0
- twig/twig: ^3.10
Requires (Dev)
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-04-30 14:05:09 UTC
README
Modular page builder for Symfony. Build content areas from sections, columns and blocks, with an extensible block-type system.
This package provides the core: entities, admin UI (Live Components + Stimulus), ContentAreaType form, and the block-type registry. Use it together with klehm/content-blocks-kit for ready-to-use blocks (Text, Title, Image, Tabs).
Requirements
- PHP >= 8.2 (>= 8.4 for Symfony 8.0)
- Symfony 6.4 LTS, 7.x or 8.x
- Doctrine ORM ^2.12 or ^3.0
Installation
The package is not tagged yet. Until 0.1.0-alpha ships, install the dev branch:
composer require klehm/content-blocks:dev-main klehm/content-blocks-kit:dev-main
If your project uses minimum-stability: stable, either lower it to dev (with prefer-stable: true) or add the :dev-main constraint as shown above.
Bundle registration & routes
If you use Symfony Flex, the auto-generated recipe registers both bundles in config/bundles.php and creates a config/routes/content_blocks.yaml that mounts the /_content-blocks/* AJAX endpoints (block CRUD, section reorder, file upload). Nothing to do.
If you don't use Flex, add them manually:
// config/bundles.php return [ // ... ContentBlocks\ContentBlocksBundle::class => ['all' => true], ContentBlocks\Kit\ContentBlocksKitBundle::class => ['all' => true], ];
# config/routes/content_blocks.yaml content_blocks: resource: '@ContentBlocksBundle/config/routes.php'
Stimulus controllers & admin CSS (required, manual until a Flex recipe ships)
The host's Symfony Stimulus Bundle reads assets/controllers.json from your project — it does not auto-discover controllers shipped by third-party packages. Without an entry for each controller, the builder UI loads no JS and the "Edit content" button does nothing.
Add the following to assets/controllers.json:
{
"controllers": {
"@klehm/content-blocks": {
"cb-builder-launcher": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"@klehm/content-blocks/styles/admin.css": true
}
},
"cb-builder": { "enabled": true, "fetch": "eager" },
"cb-block-edit-keys": { "enabled": true, "fetch": "eager" },
"cb-section-settings-form": { "enabled": true, "fetch": "eager" }
},
"@klehm/content-blocks-kit": {
"cb-file-upload": { "enabled": true, "fetch": "eager" }
}
},
"entrypoints": []
}
Then re-run php bin/console asset-map:compile (or your normal asset build).
The autoimport block on cb-builder-launcher pulls in admin.css (styles for the launcher button, builder dialog and sidebars). You do not need to add import '@klehm/content-blocks/styles/admin.css' in app.js — Stimulus Bundle handles it once the entry above is in place.
A Symfony Flex recipe that injects this whole block automatically is on the roadmap — once published, this manual step goes away.
Public assets loaded inside the preview iframe
The bundle exposes three routes under /_content-blocks/public/* that serve the styles and the overlay JS injected into the front-end iframe:
/_content-blocks/public/layout→text/css(PUBLIC + PREVIEW)/_content-blocks/public/builder→text/css(PREVIEW only)/_content-blocks/public/preview-overlay→application/javascript(PREVIEW only)
The render template injects these <link> and <script> tags itself, so the host has nothing to wire. They are deliberately split out from the admin endpoints (/_content-blocks/sections/*, /_content-blocks/blocks/*, /_content-blocks/upload) so a host can lock the admin endpoints down without 404-ing the iframe assets — see Firewalls & access control below.
Database schema
This package ships Doctrine entities (cb_content_area, cb_section, cb_column, cb_block) but no migrations — generate them in your own pipeline:
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate
Or, for a brand-new database:
php bin/console doctrine:schema:update --force
Quick start
Attach a ContentArea to your own entity (e.g. Page). The cascade: ['persist', 'remove'] is required — ContentAreaType returns a transient ContentArea on submit and relies on cascade to commit it together with the host entity:
use ContentBlocks\Entity\ContentArea; #[ORM\Entity] class Page { #[ORM\OneToOne(targetEntity: ContentArea::class, cascade: ['persist', 'remove'])] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] private ?ContentArea $contentArea = null; }
Render the builder in any Symfony form:
$builder->add('contentArea', ContentAreaType::class);
Render the ContentArea on the public page
This step is required — without it the builder iframe loads a page with no editable markers, so add-section trays, block toolbars and the preview overlay never appear.
The builder is a thin shell that opens the host's public URL inside an iframe. All the in-context editing UI (section/block guides, "+ section" tray, overlay script) is injected by the package's render template inside that public page, so the public template must call cb_render_content_area() to produce the markers Stimulus controllers attach to:
{# templates/page/show.html.twig — your public template #} <article> <h1>{{ page.title }}</h1> {{ cb_render_content_area(page.contentArea) }} </article>
Render-mode is auto-detected from the request: a query string ?cb_preview=1 combined with AccessCheckerInterface::canEdit() granting access switches to preview mode (markers + overlay injected); anything else falls through to public mode (clean published HTML, no markers).
Lifecycle
ContentAreaType does not write to the database on a GET request. If the host entity has no ContentArea yet (new entity, or legacy data), the widget renders a "save first" placeholder instead of the builder. Once the form is submitted and the host entity is persisted, the next edit shows the builder normally.
Required host services
Two interfaces have no useful default and must be configured by the host app:
AccessCheckerInterface — authorization
ContentBlocks does not know your auth model. The default (DenyAllAccessChecker) blocks every mutation. Provide your own:
# config/services.yaml ContentBlocks\Security\AccessCheckerInterface: class: App\Security\PageAccessChecker
use ContentBlocks\Security\AccessCheckerInterface; use ContentBlocks\Entity\ContentArea; final class PageAccessChecker implements AccessCheckerInterface { public function canEdit(ContentArea $contentArea): bool { // Check that the current user owns the Page linked to this ContentArea } public function canView(ContentArea $contentArea): bool { return true; } }
ContentAreaUrlResolverInterface — preview URL
The builder shell loads the public page in an iframe to preview edits in context. The resolver maps a ContentArea back to the host's public URL. The default (NullContentAreaUrlResolver) throws — without a real implementation, rendering the widget fails:
# config/services.yaml ContentBlocks\Preview\ContentAreaUrlResolverInterface: class: App\Preview\PageContentAreaUrlResolver
use ContentBlocks\Entity\ContentArea; use ContentBlocks\Preview\ContentAreaUrlResolverInterface; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; final class PageContentAreaUrlResolver implements ContentAreaUrlResolverInterface { public function __construct( private readonly EntityManagerInterface $em, private readonly UrlGeneratorInterface $urls, ) {} public function resolve(ContentArea $area): string { $page = $this->em->getRepository(Page::class)->findOneBy(['contentArea' => $area]); if (!$page) { // Fallback while the parent entity is being created and is not yet linked return $this->urls->generate('app_home'); } return $this->urls->generate('app_page_show', ['id' => $page->getId()]); } }
File storage (optional, only if your blocks accept uploads)
ContentBlocks\Storage\FileStorageInterface: class: ContentBlocks\Storage\LocalFileStorage arguments: $uploadDir: '%kernel.project_dir%/public/uploads/content-blocks' $publicPrefix: '/uploads/content-blocks'
Security notes
CSRF
AJAX endpoints (/_content-blocks/*) require an X-CSRF-Token header bound to the token id content_blocks. Stimulus controllers read it from a data-cb-csrf-token attribute rendered by the bundle. Your app needs:
framework.session: true(CSRF tokens are session-bound)framework.csrf_protection.enabled: true
Firewalls & access control
The bundle exposes two URL families with different exposure:
| Path prefix | Audience | Mode |
|---|---|---|
/_content-blocks/public/* |
Anyone (loaded inside the public iframe) | Public |
/_content-blocks/* (everything else) |
Authenticated admin (block CRUD, section CRUD, sidebars, upload) | Admin-only |
The public sub-prefix is intentional: it lets you lock the admin endpoints down without breaking the iframe's CSS and overlay JS.
With a single firewall, an access_control split is enough:
# config/packages/security.yaml security: access_control: - { path: ^/_content-blocks/public, roles: PUBLIC_ACCESS } - { path: ^/_content-blocks, roles: ROLE_ADMIN }
With separate admin and front-office firewalls, extend the admin firewall's pattern to cover the admin endpoints (and exclude the public sub-prefix), otherwise the builder's AJAX calls run unauthenticated:
security: firewalls: admin: pattern: ^/(admin|_content-blocks(?!/public)) # ... main: # public site — handles the iframe URL, no admin auth here pattern: ^/
Cross-firewall auth detection in AccessCheckerInterface
The render template auto-detects preview mode by calling AccessCheckerInterface::canEdit() while serving the public URL — i.e. the request passes through the public/main firewall, but the user authenticated against the admin firewall. With separate firewall contexts (context: admin), Symfony's standard Security::isGranted() will not see the admin token from the main firewall and the iframe falls back to public mode (no editing UI, even when an admin opens the builder).
If your firewalls use isolated contexts, the access checker has to read the admin token directly from the session:
use ContentBlocks\Security\AccessCheckerInterface; use ContentBlocks\Entity\ContentArea; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; final class PageAccessChecker implements AccessCheckerInterface { public function __construct( private readonly TokenStorageInterface $tokens, private readonly RequestStack $requests, ) {} public function canEdit(ContentArea $contentArea): bool { return $this->isAdmin() && $this->ownsArea($contentArea); } public function canView(ContentArea $contentArea): bool { return true; } private function isAdmin(): bool { // 1) Standard path: a token is in the current firewall's storage. $token = $this->tokens->getToken(); if ($token && \in_array('ROLE_ADMIN', $token->getRoleNames(), true)) { return true; } // 2) Cross-firewall fallback: the iframe runs under the public // firewall, so the admin token isn't visible via $tokens. Read // the serialized admin token from the session directly. The key // is `_security_<context_or_firewall_name>` — `_security_admin` // when `context: admin` or the firewall name is `admin`. $request = $this->requests->getMainRequest(); if (!$request || !$request->hasSession()) { return false; } $serialized = $request->getSession()->get('_security_admin'); if (!\is_string($serialized)) { return false; } $adminToken = unserialize($serialized); return $adminToken instanceof TokenInterface && \in_array('ROLE_ADMIN', $adminToken->getRoleNames(), true); } private function ownsArea(ContentArea $area): bool { // your app's ownership check } }
Known install-time warnings
composer audit may flag doctrine/annotations as abandoned. This package does not require doctrine/annotations — the warning comes from your host project (typically pulled in by an older Symfony Framework Bundle setup or a legacy Doctrine config). Remove it with composer remove doctrine/annotations and set framework.annotations: false in your config if your app no longer uses annotation-based metadata.
Documentation & contributing
Full development setup, sandbox apps, and JS test suite live in the monorepo: github.com/klehm/content-blocks-project
License
MIT