PHPackages                             klehm/content-blocks - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Admin Panels](/categories/admin)
4. /
5. klehm/content-blocks

ActiveSymfony-bundle[Admin Panels](/categories/admin)

klehm/content-blocks
====================

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

v0.1.0-alpha.11(3w ago)031↓100%1MITPHPPHP &gt;=8.2

Since Apr 30Pushed 3w agoCompare

[ Source](https://github.com/Klehm/content-blocks)[ Packagist](https://packagist.org/packages/klehm/content-blocks)[ Docs](https://github.com/klehm/content-blocks-project)[ RSS](/packages/klehm-content-blocks/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (11)Versions (12)Used By (1)

ContentBlocks
=============

[](#contentblocks)

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`](https://github.com/klehm/content-blocks-kit) for ready-to-use blocks (Text, Title, Image, Tabs).

Requirements
------------

[](#requirements)

- PHP &gt;= 8.2 (&gt;= 8.4 for Symfony 8.0)
- Symfony 6.4 LTS, 7.x or 8.x
- Doctrine ORM ^2.12 or ^3.0

Installation
------------

[](#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 &amp; routes

[](#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 &amp; admin CSS (required, manual until a Flex recipe ships)

[](#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-autosave":              { "enabled": true, "fetch": "eager" },
            "cb-section-settings-form": { "enabled": true, "fetch": "eager" },
            "cb-spacing-link":          { "enabled": true, "fetch": "eager" },
            "cb-viewport-tabs":         { "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

[](#public-assets-loaded-inside-the-preview-iframe)

The bundle exposes four 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/styling` → `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 `` and `` 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 &amp; access control](#firewalls--access-control) below.

### Database schema

[](#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
-----------

[](#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

[](#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 #}

    {{ page.title }}
    {{ cb_render_content_area(page.contentArea) }}

```

`cb_render_content_area()` accepts `null` and renders an empty string in that case, so you don't need an `{% if page.contentArea %}` guard around it when the host entity may not yet have a linked area.

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

### Overriding render templates

[](#overriding-render-templates)

The render pipeline is split into four templates so you can override the markup of an individual level (section, column, block) without forking the whole entry-point. Drop a file at the same relative path under `templates/bundles/ContentBlocksBundle/` in your host app to override one.

> Requires `klehm/content-blocks >= 0.1.0-alpha.4` for overrides to take priority. Earlier versions manually registered the vendor `templates/` path under `@ContentBlocks`, which (counter-intuitively) shadowed the host's `templates/bundles/ContentBlocksBundle/` directory.

TemplateReceivesResponsibility`@ContentBlocks/render/content_area.html.twig``sections` (array), `mode` (`RenderMode`), `blockTypes` (array)Top-level wrapper, layout/builder CSS ``s, sections loop, preview-only section tray + overlay scripts.`@ContentBlocks/render/section.html.twig``section` (`Section`), `isPreview` (bool)`` element, inline styles + extra attributes from section decorators, columns loop.`@ContentBlocks/render/column.html.twig``column` (`Column`), `isPreview` (bool)`` element, blocks loop, preview-only "+ block" inline button.`@ContentBlocks/render/block.html.twig``block` (`Block`), `isPreview` (bool)`` element, include of `block.viewTemplate` with `data`.Sub-templates are included with `with_context = false` — the listed variables are the contract; anything else from the parent scope is not available.

If you override `section`/`column`/`block`, keep the existing `cb-*` classes and `data-cb-*` attributes intact. The builder's Stimulus controllers and the preview-overlay script attach to those selectors; renaming them breaks the in-context editing UI.

### Lifecycle

[](#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
----------------------

[](#required-host-services)

Two interfaces have no useful default and **must** be configured by the host app:

### `AccessCheckerInterface` — authorization

[](#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

[](#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()]);
    }
}
```

### `ContentAreaProviderInterface` — replace-content picker (optional)

[](#contentareaproviderinterface--replace-content-picker-optional)

The builder's **Insert content** button (topbar) lets editors overwrite the current area with the content of any other `ContentArea` in the system. The picker is populated by a host-provided query so users see meaningful labels (page title, slug, last edit…) instead of opaque ids.

A default implementation ships with the bundle: it searches by id and labels rows as `# — `. It works out of the box but is rarely the right UX — implement the interface and alias it in your `services.yaml` to surface what your editors actually search on:

```
# config/services.yaml
ContentBlocks\Replace\ContentAreaProviderInterface:
    class: App\ContentBlocks\PageContentAreaProvider
```

```
use App\Entity\Page;
use ContentBlocks\Entity\ContentArea;
use ContentBlocks\Replace\ContentAreaProviderInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;

final class PageContentAreaProvider implements ContentAreaProviderInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function createQueryBuilder(?string $filter): QueryBuilder
    {
        // Join through the host's owning entity (Page) so the picker can
        // search on title + return only areas that have a real Page parent.
        $qb = $this->em->createQueryBuilder()
            ->select('a')
            ->from(ContentArea::class, 'a')
            ->innerJoin(Page::class, 'p', 'WITH', 'p.contentArea = a');

        if ($filter !== null && $filter !== '') {
            $qb->andWhere('p.title LIKE :q')->setParameter('q', '%' . $filter . '%');
        }

        return $qb;
    }

    public function getLabel(ContentArea $area): string
    {
        $page = $this->em->getRepository(Page::class)->findOneBy(['contentArea' => $area]);
        if (!$page) {
            return '#' . $area->getId();
        }
        $when = $area->getUpdatedAt()?->format('Y-m-d') ?? '—';

        return sprintf('%s — %s', $page->getTitle(), $when);
    }
}
```

The controller appends ordering (`updatedAt DESC` then `id DESC`) and pagination (10 items + 1 sentinel for `hasMore`); the target area is always excluded from results. `ContentArea::updatedAt` is touched by a Doctrine `onFlush` listener whenever any descendant Section / Column / Block changes — your provider does not need to maintain it.

The replace itself writes to the **draft** state on the target: existing sections are soft-deleted and clones of the source's sections are inserted. The user then publishes (commits the swap) or discards (restores the original content).

### File storage (optional, only if your blocks accept uploads)

[](#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'
```

Styling sections and blocks
---------------------------

[](#styling-sections-and-blocks)

Each section's settings sidebar carries a **Styling** tab with padding, margin (per viewport), background color, min-height and alignment. Block edit forms carry the same tab with padding, margin, background color and max-width.

These fields land in JSON under `settings.styling` for sections and `data.styling` for blocks. They are stored as-is — no DB migration; existing content keeps working untouched.

At render time, two decorators (`StylingSectionDecorator`, `StylingBlockDecorator`) translate the values into **CSS custom properties** on the outer element, and a stylesheet shipped at `/_content-blocks/public/styling` maps those vars to real properties with `@media` rules for tablet (`max-width: 991px`) and mobile (`max-width: 575px`) — so per-viewport overrides actually work (inline `style` can't carry media queries).

The fallback chain inside each `@media` block is: mobile → tablet → desktop → 0. A viewport you leave blank inherits the next-wider one.

### Extending the Styling sub-form

[](#extending-the-styling-sub-form)

The `StylingType` form holds the styling fields. Register a Symfony `FormTypeExtension` against it to inject or override fields without forking — they will render inside the sidebar's **Styling** tab:

```
use ContentBlocks\Form\Type\Styling\StylingType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;

final class BrandPaletteExtension extends AbstractTypeExtension
{
    public static function getExtendedTypes(): iterable
    {
        return [StylingType::class];
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // Re-adding an existing field overrides it — here we replace the
        // raw HTML5 ColorType with a curated brand palette.
        $builder->add('backgroundColor', ChoiceType::class, [
            'required' => false,
            'choices' => [
                'Brand / Primary' => '#0a84ff',
                'Brand / Accent' => '#ff375f',
            ],
        ]);
    }
}
```

See the sandbox at [apps/content-blocks-sandbox/src/Form/Extension/StylingPaletteExtension.php](apps/content-blocks-sandbox/src/Form/Extension/StylingPaletteExtension.php) for a runnable example.

### Adding your own block decorator

[](#adding-your-own-block-decorator)

Implement `ContentBlocks\Block\BlockDecoratorInterface` (mirror of `SectionDecoratorInterface`). It is auto-tagged with `content_blocks.block_decorator` when `autoconfigure: true` is on, and called for every block being rendered. Return a `BlockDecoration` (classes / inline styles / attributes) — the bundle merges all decorators' output into the block's outer ``.

Customizing default values
--------------------------

[](#customizing-default-values)

A few section and block fields ship with a baked-in default so the form always presents a usable value and the renderer can fall back when the user leaves a field empty. The two surfaces (form pre-fill + renderer fallback) read the **same source**, so changing the default in one place keeps them in sync.

### Section `maxWidth` (built-in)

[](#section-maxwidth-built-in)

When the user picks **Centered** width without typing a number, the section is capped at **1320px**. The same value pre-fills the input box and shows up as the placeholder. Typing `0` explicitly opts out of any cap.

The number is exposed as a service parameter — the simplest override is one line of YAML:

```
# config/services.yaml
parameters:
    content_blocks.section.default_max_width: 1400
```

Both `BuiltInSectionDecorator` and `CoreSectionDefaults` are bound to this parameter, so the form pre-fill, placeholder, and rendered fallback all move together.

### Adding (or overriding) defaults via a provider

[](#adding-or-overriding-defaults-via-a-provider)

For multi-key defaults, nested values, or anything computed at runtime, register a `SectionSettingsDefaultsProviderInterface`:

```
use ContentBlocks\Section\SectionSettingsDefaultsProviderInterface;

final class AppSectionDefaults implements SectionSettingsDefaultsProviderInterface
{
    public function getDefaults(): array
    {
        return [
            // Top-level section setting.
            'maxWidth' => 1400,
            // Nested under the Styling sub-form (deep-merged).
            'styling' => [
                'backgroundColor' => '#f7f7f7',
            ],
        ];
    }
}
```

The interface is autoconfigured — no tag needed. All providers are aggregated via `array_replace_recursive`, **later providers win on key conflict**, so a host provider always overrides `CoreSectionDefaults` / `CoreStylingDefaults`.

At render time, values **equal to the default are stripped** from the saved settings before the decorator pipeline runs (`SectionSettingsDefaults::withoutDefaults()`) — so a section saved with the default cap produces no inline `max-width` style; only user-overridden values do. The decorator re-applies the default itself when the key is missing.

### Block-side equivalent

[](#block-side-equivalent)

For block defaults, implement `ContentBlocks\Block\BlockDataDefaultsProviderInterface` (mirror of the section interface). It's the same pattern: form pre-fill + `BlockDataDefaults::withoutDefaults()` at render. The package's `CoreBlockStylingDefaults` sets `styling.backgroundColor = #ffffff` to dodge the `` black fallback — extend it the same way.

Security notes
--------------

[](#security-notes)

### CSRF

[](#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 &amp; access control

[](#firewalls--access-control)

The bundle exposes two URL families with different exposure:

Path prefixAudienceMode`/_content-blocks/public/*`Anyone (loaded inside the public iframe)Public`/_content-blocks/*` (everything else)Authenticated admin (block CRUD, section CRUD, sidebars, upload)Admin-onlyThe 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`

[](#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_` — `_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
---------------------------

[](#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 &amp; contributing
--------------------------------

[](#documentation--contributing)

Full development setup, sandbox apps, and JS test suite live in the monorepo: [github.com/klehm/content-blocks-project](https://github.com/klehm/content-blocks-project)

License
-------

[](#license)

MIT

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance96

Actively maintained with recent releases

Popularity10

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity38

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

11

Last Release

21d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/055eb982eabf9a57364115de95d0bef94a2c9e2e36ac8ad8439bdf7932305ccf?d=identicon)[Klehm](/maintainers/Klehm)

---

Top Contributors

[![Klehm](https://avatars.githubusercontent.com/u/6897289?v=4)](https://github.com/Klehm "Klehm (18 commits)")

---

Tags

symfonycontentcmsblocksSymfony Bundlepage builderlive-components

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/klehm-content-blocks/health.svg)

```
[![Health](https://phpackages.com/badges/klehm-content-blocks/health.svg)](https://phpackages.com/packages/klehm-content-blocks)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.5M370](/packages/easycorp-easyadmin-bundle)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k5.8M710](/packages/sylius-sylius)[forumify/forumify-platform

132.0k12](/packages/forumify-forumify-platform)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M195](/packages/sulu-sulu)[prestashop/prestashop

PrestaShop is an Open Source e-commerce platform, committed to providing the best shopping cart experience for both merchants and customers.

9.1k16.8k](/packages/prestashop-prestashop)[2lenet/crudit-bundle

The easy like Crud'it Bundle.

1715.6k12](/packages/2lenet-crudit-bundle)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
