klehm/content-blocks

Modular page builder for Symfony — entities, admin UI, form integration.

Maintainers

Package info

github.com/Klehm/content-blocks

Homepage

Issues

Type:symfony-bundle

pkg:composer/klehm/content-blocks

Statistics

Installs: 3

Dependents: 1

Suggesters: 0

Stars: 0

v0.1.0-alpha.2 2026-04-30 10:10 UTC

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/layouttext/css (PUBLIC + PREVIEW)
  • /_content-blocks/public/buildertext/css (PREVIEW only)
  • /_content-blocks/public/preview-overlayapplication/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