web-systems / gutenberg-bundle
Gutenberg (WordPress block editor) as a Symfony form field: visual page building, block storage, server-side rendering and PHP-defined custom blocks.
Package info
github.com/websystemspl/gutenberg-bundle
Type:symfony-bundle
pkg:composer/web-systems/gutenberg-bundle
Requires
- php: >=8.2
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/config: ^7.3 || ^8.0
- symfony/dependency-injection: ^7.3 || ^8.0
- symfony/filesystem: ^7.3 || ^8.0
- symfony/finder: ^7.3 || ^8.0
- symfony/form: ^7.3 || ^8.0
- symfony/framework-bundle: ^7.3 || ^8.0
- symfony/http-foundation: ^7.3 || ^8.0
- symfony/http-kernel: ^7.3 || ^8.0
- symfony/routing: ^7.3 || ^8.0
- symfony/twig-bundle: ^7.3 || ^8.0
- twig/twig: ^3.10
Requires (Dev)
- doctrine/doctrine-bundle: ^2.12 || ^3.0
- doctrine/orm: ^3.0
- easycorp/easyadmin-bundle: ^4.10 || ^5.0
- symfony/console: ^7.3 || ^8.0
- symfony/http-client: ^7.3 || ^8.0
- symfony/phpunit-bridge: ^7.3 || ^8.0
- symfony/process: ^7.3 || ^8.0
- symfony/security-core: ^7.3 || ^8.0
- symfony/validator: ^7.3 || ^8.0
Suggests
- doctrine/doctrine-bundle: To store block revisions and the reusable-block library.
- easycorp/easyadmin-bundle: To use GutenbergField inside EasyAdmin CRUD controllers.
- symfony/console: To use the gutenberg:update and make:gutenberg-block commands.
- symfony/http-client: Required by the gutenberg:update command.
- symfony/process: Required by gutenberg:update --build to run the npm build.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-10 06:31:19 UTC
README
The WordPress block editor (Gutenberg) as a Symfony form field.
Add one field to a form and editors get the real block editor — the upstream
@wordpress/block-editor packages, not a re-implementation. Content is stored as ordinary
block markup, rendered on the front end with one Twig call, and extended with custom blocks
that are written entirely in PHP.
$builder->add('content', GutenbergType::class);
{{ gutenberg_render(page.content) }}
Why block markup
The field's value is the same string WordPress stores: HTML annotated with block delimiters.
<!-- wp:heading --><h2>Hello</h2><!-- /wp:heading --> <!-- wp:app/hero {"title":"Welcome"} /-->
Static blocks carry their own markup, so rendering them costs nothing on the server. Dynamic blocks store attributes only and are rendered by PHP at request time. Content stays portable: it can be moved to or from WordPress unchanged.
Installation
composer require web-systems/gutenberg-bundle
Register the bundle (Flex does this for you) and import the routes it needs for server-side previews, media and translations:
# config/routes/web_systems_gutenberg.yaml web_systems_gutenberg: resource: '@WebSystemsGutenbergBundle/config/routes.php' prefix: /_gutenberg
Publish the editor assets:
bin/console assets:install public
If Doctrine is installed, register the bundle's migrations and run them — they create the revision and reusable-block tables:
# config/packages/doctrine_migrations.yaml doctrine_migrations: migrations_paths: 'WebSystems\GutenbergBundle\Migrations': '%kernel.project_dir%/vendor/web-systems/gutenberg-bundle/migrations'
bin/console doctrine:migrations:migrate
The bundle works without Doctrine too; revisions and reusable blocks are then simply absent.
Using the field
Plain Symfony forms
$builder->add('content', GutenbergType::class, [ 'editor_height' => 800, 'allowed_blocks' => ['core/paragraph', 'core/heading', 'core/image'], 'block_template' => [['core/heading', ['level' => 2]], ['core/paragraph', []]], 'template_lock' => 'insert', ]);
The field is a TextareaType underneath: the value stays a plain string, validation and
persistence work as usual, and the form degrades to a textarea when JavaScript is unavailable.
The editor bundle is attached automatically, so a hand-written admin panel needs no extra work.
EasyAdmin
use WebSystems\GutenbergBundle\EasyAdmin\Field\GutenbergField; yield GutenbergField::new('content', 'Page content') ->setHeight(760) ->renderOnDetail();
The field attaches its own CSS and JS, so configureAssets() stays untouched. Register the
form theme in the CRUD controller so the widget is picked up:
public function configureCrud(Crud $crud): Crud { return $crud->setFormThemes([ '@WebSystemsGutenberg/form/gutenberg_widget.html.twig', '@EasyAdmin/crud/form_theme.html.twig', ]); }
Rendering on the front end
{{ gutenberg_front_assets() }} {# stylesheets for core blocks #}
{{ gutenberg_render(page.content) }} {# the blocks #}
{{ gutenberg_render(page.content, {currentPageId: page.id}) }} {# with render context #}
{{ gutenberg_excerpt(page.content, 160) }}
{% if gutenberg_has_blocks(page.content) %}…{% endif %}
{% for block in gutenberg_blocks(page.content) %}{{ block.name }}{% endfor %}
In PHP, depend on ContentRendererInterface:
public function __construct(private ContentRendererInterface $renderer) {} $html = $this->renderer->render($page->getContent());
Custom blocks
A custom block is one PHP class and one Twig template. There is no JavaScript to write and no asset to rebuild: the editor reads the field schema at runtime and generates the inspector panel, while the canvas shows the real server-rendered output.
bin/console make:gutenberg-block Hero
#[AsBlock(
name: 'app/hero',
title: 'Hero',
icon: 'cover-image',
category: 'cms',
template: 'blocks/hero.html.twig',
)]
final class HeroBlock extends AbstractBlockType
{
public function configureAttributes(AttributeBuilder $builder): void
{
$builder
->richText('title', 'Heading', 'Welcome')
->textarea('lead', 'Intro text')
->image('background', 'Background image')
->color('overlay', 'Overlay colour', '#1d3557')
->range('overlayOpacity', 'Overlay strength (%)', 55, 0, 100, 5)
->select('align', 'Alignment', ['left' => 'Left', 'center' => 'Centre'], 'center')
->url('cta', 'Button');
}
}
{# templates/blocks/hero.html.twig #} <section class="c-hero" style="background-color: {{ attributes.overlay }}"> <h1>{{ attributes.title|raw }}</h1> {% if attributes.cta.url is defined %}<a href="{{ attributes.cta.url }}">{{ attributes.cta.label }}</a>{% endif %} </section>
Available field types: text, textarea, richText, html, number, range, toggle,
select, color, image, url. Pass inContent: true to edit a field directly on the
canvas instead of in the sidebar.
The icon argument takes a name from @wordpress/icons
in camelCase or kebab-case (listView, list-view), one of the common Dashicon slugs, which
are mapped to their SVG equivalents, or raw <svg> markup. Bare Dashicon slugs alone would
render nothing: Dashicons are a font shipped with WordPress core, not with any npm package, so
an unmapped name falls back to the default block icon rather than to an empty space.
Blocks are ordinary services, so they may inject anything:
public function __construct(private PageRepository $pages) {} public function render(BlockRenderContext $context): string { return $this->getTwig()->render($this->getTemplate(), [ 'attributes' => $context->attributes, 'pages' => $this->pages->findPublished((int) $context->get('limit', 3)), ]); }
Set innerBlocks: true on #[AsBlock] to let editors nest other blocks inside; the rendered
children arrive in the template as inner.
Inspect what is registered:
bin/console debug:gutenberg-blocks bin/console debug:gutenberg-blocks app/hero
Theme colours and typography
The editor has no theme of its own: the palette, the gradients and the font sizes it offers all come from configuration, and reach both the core blocks and the controls of your own blocks.
web_systems_gutenberg: editor: palette: - { name: 'Brand', slug: 'brand', color: '#1d3557' } - { name: 'Accent', slug: 'accent', color: '#e63946' } gradients: # Double every % sign: Symfony reads a single one as a container parameter. - { name: 'Deep', slug: 'deep', gradient: 'linear-gradient(135deg, #1d3557 0%%, #457b9d 100%%)' } font_sizes: - { name: 'Large', slug: 'large', size: '24px' } custom_colors: true # false restricts editors to the palette above
Colours land in the editor as a theme palette (__experimentalFeatures.color.palette.theme),
which is what the Colour panel of core blocks and the color() field of custom blocks both
read.
Picking a preset does not write the value into the markup — Gutenberg writes a class:
<p class="has-accent-background-color has-background">…</p>
The bundle therefore generates the stylesheet that gives those classes meaning, the same
custom properties and !important preset rules WordPress builds from theme.json. It is emitted
by gutenberg_front_assets() and injected into the editor canvas, so a colour chosen while
editing is the colour a visitor sees. Set preset_styles: false if your own stylesheet already
defines has-*-color, has-*-background-color, has-*-border-color,
has-*-gradient-background and has-*-font-size. WordPress' own default palette is deliberately absent — it lives in the theme.json that a
WordPress install provides and this bundle does not — so what you configure is exactly what
editors see. Set custom_colors: false to remove the free colour picker and keep documents on
brand.
Reusable blocks
A reusable block is a fragment saved once and referenced from many documents; editing it updates every page that uses it. It is stored the way WordPress stores a synced pattern, so the markup travels between the two systems unchanged:
<!-- wp:block {"ref":7} /-->
Creating one from the editor
Select the blocks to reuse, open the block's Options menu (⋮) and choose Create
pattern. The selection is saved under the name you give it and replaced in place by a
reference, so the document ends up holding <!-- wp:block {"ref":N} /--> and the new block is
immediately available in the inserter.
Managing them
With Doctrine and EasyAdmin installed the bundle registers a management screen at
/admin/reusable-block automatically. Saved blocks appear in the editor's inserter under their
own category, each by name, and the canvas previews them server-side.
Turn the built-in screen off when your application ships its own — EasyAdmin refuses two CRUD controllers that share a short class name:
web_systems_gutenberg: easyadmin: reusable_block_crud: false
Applications without EasyAdmin drive ReusableBlockRepository from their own panel; the entity
is WebSystems\GutenbergBundle\Entity\ReusableBlock with a title, a slug and content.
References resolve by numeric id or by slug, and a block that references itself, directly or
through another one, is stopped by the render-depth guard rather than looping.
Interface language
The editor ships in English and is translated with the official WordPress.org language packs.
bin/console gutenberg:translations pl_PL bin/console gutenberg:translations --list
Block titles, descriptions and category names are translated as well: their source is
block.json, which WordPress translates while building its registry, so the bundle passes them
through the catalogue as each block registers.
The catalogue is written to var/gutenberg/translations/ and served to the editor over a
cacheable endpoint. The locale defaults to %kernel.default_locale%; set
web_systems_gutenberg.editor.locale to override it. Right-to-left locales are detected
automatically, but you must point assets.editor_styles at editor-rtl.css yourself.
Keeping the editor up to date
The editor is built from published @wordpress/* packages.
bin/console gutenberg:update # report outdated packages bin/console gutenberg:update --update # pin the latest versions bin/console gutenberg:update --build # reinstall and rebuild public/editor.js
Rebuilding requires Node.js and is only needed by whoever maintains the package; installing the bundle from Packagist ships the built assets.
Extension points
Every part of the pipeline is an interface with a tagged implementation, so behaviour is added by registering a service rather than by patching the bundle.
| Interface | Purpose |
|---|---|
BlockTypeInterface |
a server-rendered block; register with #[AsBlock] |
BlockRendererInterface |
a rendering strategy for a family of blocks, priority-ordered |
ContentRendererInterface |
the composite renderer consumers depend on |
EditorSettingsProviderInterface |
contributes a slice of the editor configuration |
MediaStorageInterface |
where uploaded images live |
RevisionStorageInterface |
how content snapshots are kept |
ReusableBlockProviderInterface |
resolves core/block references |
ReusableBlockWriterInterface |
stores a selection saved from the editor |
TranslationCatalogueInterface |
supplies the interface message map |
EditorAccessCheckerInterface |
guards the editor's endpoints |
Configuration reference
web_systems_gutenberg: editor: height: 720 locale: '%kernel.default_locale%' content_width: '840px' # width of the content column inside the canvas wide_width: '1140px' # width of "wide" aligned blocks; null disables them canvas_padding_block: '44px' # breathing room above and below the canvas content canvas_padding_inline: '28px' # …and beside it; full-width blocks escape this gutter allowed_blocks: [] canvas_styles: ['/css/front.css'] # your theme, appended to assets.canvas_styles custom_colors: true preset_styles: true # generate CSS for the has-*-color classes the editor writes palette: - { name: 'Brand', slug: 'brand', color: '#1d3557' } gradients: - { name: 'Deep', slug: 'deep', gradient: 'linear-gradient(135deg,#1d3557,#457b9d)' } font_sizes: - { name: 'Large', slug: 'large', size: '24px' } categories: - { slug: 'cms', title: 'CMS blocks' } assets: editor_styles: ['bundles/websystemsgutenberg/editor.css'] editor_scripts: ['bundles/websystemsgutenberg/editor.js'] front_styles: ['bundles/websystemsgutenberg/front.css'] # Loaded inside the canvas iframe, which is a separate document: without these the # in-canvas UI (appenders, placeholders, the Columns layout picker) is unstyled. canvas_styles: - 'bundles/websystemsgutenberg/canvas.css' - 'bundles/websystemsgutenberg/front.css' media: enabled: true directory: '%kernel.project_dir%/public/uploads/gutenberg' public_prefix: '/uploads/gutenberg' max_file_size: 8388608 allowed_mime_types: ['image/jpeg', 'image/png', 'image/webp'] translations: enabled: true directory: '%kernel.project_dir%/var/gutenberg/translations' http_cache_max_age: 86400 revisions: enabled: true limit: 20 easyadmin: reusable_block_crud: true # register the built-in reusable-block CRUD screen security: access_role: ~ # e.g. ROLE_ADMIN max_render_depth: 10 doctrine: ~ # null auto-detects DoctrineBundle
Security
The endpoints under the imported prefix (/_gutenberg by convention) render blocks, list and
accept media, and expose the editor configuration. Put them behind your firewall:
security: access_control: - { path: ^/_gutenberg, roles: ROLE_ADMIN }
Setting web_systems_gutenberg.security.access_role adds a second check inside the bundle, for
defence in depth or for applications that do not use access_control.
Block content is trusted HTML, exactly as in WordPress: whoever can use the editor can emit
markup. Restrict the editor to trusted roles, or narrow editor.allowed_blocks.
License
MIT.