Block Editor
Every #[AsBlock] class is authorable in the block editor with no JavaScript and no build step: write a PHP class and a Twig template, and Føhn derives the sidebar controls and a real, server-rendered preview from them.
The model
A Foehn block already declares an attribute schema in attributes() and renders itself server-side through render() and compose(). That schema is the only input the block editor needs.
Føhn ships one generic script — the registrar — that reads the schema of every discovered block and, for each one, registers an edit component built from the schema alone: a sidebar field per attribute, and a preview powered by the block's own render_callback. There is no per-block script to write, no edit.js, and nothing to compile.
#[AsBlock(
name: 'theme/alert',
title: 'Alert',
category: 'widgets',
icon: 'warning',
)]
final readonly class AlertBlock implements BlockInterface
{
public static function attributes(): array
{
return [
'type' => ['type' => 'string', 'default' => 'info'],
'message' => ['type' => 'string', 'default' => ''],
];
}
// compose() and render() as usual
}This class alone is enough to get a working sidebar with two fields, type and message, and a preview that shows the real Twig output. Nothing else needs to change.
Core's default styles win on load order
WordPress prints the styles it derives from theme.json inline, and after the theme's enqueued stylesheet. Later and equal specificity means core wins.
With no theme.json, those are core's own defaults — including body { padding: 0 }, which silently flattens a gutter the theme's stylesheet sets. The symptom is a theme rule struck through in the inspector by an inline sheet the theme never enqueued, which reads as a broken stylesheet rather than a cascade conflict.
Hooks\Cleanup\DisableGlobalStyles removes them. The starter enables it, because it ships no theme.json; drop it from foehn.config.php if you adopt theme.json presets for colours, typography, spacing or duotone filters.
Controls
The control for an attribute is derived from its type, with an optional control override for cases the type alone cannot express:
| Schema | Control | Component |
|---|---|---|
'type' => 'string' | text | TextControl |
'type' => 'string', 'options' => [...] or 'enum' => [...] | select | SelectControl |
'type' => 'boolean' | toggle | ToggleControl |
'type' => 'number' or 'type' => 'integer' | number | TextControl (type="number") |
'type' => 'string', 'control' => 'textarea' | textarea | TextareaControl |
'type' => 'integer', 'control' => 'image' | image | MediaUpload |
'type' => 'array', 'control' => 'gallery' | gallery | MediaUpload (multiple) |
'type' => 'integer', 'control' => 'file' | file | MediaUpload (any type) |
'type' => 'array', 'control' => 'posts' | posts | ComboboxControl + core-data |
A string or number attribute gets a control from type alone; nothing else is required. number and integer share the same component, but the declared type still matters: an integer field rounds its value before calling setAttributes, because WordPress rejects a float against an integer schema and silently falls back to the default.
An attribute of a type with no derived control — array, object, or no type at all — gets no sidebar field. It still exists in the schema and reaches the template; it is simply not editable from the sidebar, which is the right outcome for data a block computes or receives from elsewhere rather than data an author sets.
gallery, file and posts are never derived from a type: an array says nothing about whether it holds attachment ids or post ids, so each has to be asked for by name.
gallery and posts store a list, so declare them as arrays — ['type' => 'array', 'items' => ['type' => 'integer'], 'default' => []]. Declared as a scalar, WordPress validates every selection but the first away, and debug mode warns about it.
posts is the one control with no core equivalent. It stores post ids in the order the author arranged them, offers move-up and move-down, and searches across the post types postTypes names — or every viewable type when it names none.
Six keys beyond the WordPress ones (type, default, enum, ...) describe how the attribute shows up in the sidebar:
| Key | Description |
|---|---|
control | Explicit control name: text, textarea, toggle, number, select, image, gallery, file, posts. Overrides the type-derived choice. An unsupported value falls back to the type-derived control and logs a warning in debug mode, rather than silently dropping the field. |
label | Field label in the sidebar. Defaults to a humanized version of the attribute key — ctaLabel becomes "Cta label". |
help | Help text shown under the field. |
options | Choices for a select control: a list of raw values, a value => label map, or an already normalized list of {label, value} pairs. |
allowedTypes | Media types the file or gallery picker offers, as MIME types or top-level types — ['audio'], ['image']. Omit it and a gallery offers images while a file offers anything. |
postTypes | Post types the posts control searches — ['termes', 'liens']. Omit it and every viewable post type is searched. |
public static function attributes(): array
{
return [
'variant' => [
'type' => 'string',
'default' => 'light',
'control' => 'select',
'options' => ['light' => 'Light', 'dark' => 'Dark'],
],
'imageId' => [
'type' => 'integer',
'control' => 'image',
],
'body' => [
'type' => 'string',
'control' => 'textarea',
'label' => 'Body text',
'help' => 'Shown below the title.',
],
];
}These four keys are stripped before the schema reaches WordPress: register_block_type() and block.json only ever see the attributes WordPress itself understands.
Containers
A block accepts inner blocks as soon as one of three #[AsBlock] parameters is set — allowedBlocks, innerBlocksTemplate, or innerBlocksTemplateLock. Setting any of them turns the block into a container: the editor renders InnerBlocks in the canvas instead of a server-side preview, and the inner markup reaches the Twig template as content, exactly as it does today for a block with child blocks.
#[AsBlock(
name: 'theme/hero',
title: 'Hero',
category: 'theme',
icon: 'cover-image',
allowedBlocks: ['core/heading', 'core/paragraph', 'core/buttons'],
innerBlocksTemplate: [
['core/heading', ['level' => 1, 'placeholder' => 'Hero title']],
['core/paragraph', ['placeholder' => 'Hero copy']],
],
innerBlocksTemplateLock: 'insert',
)]
final readonly class HeroBlock implements BlockInterface
{
public static function attributes(): array
{
return [
'imageId' => ['type' => 'integer', 'control' => 'image', 'label' => 'Background image'],
'align' => [
'type' => 'string',
'default' => 'left',
'control' => 'select',
'options' => ['left' => 'Left', 'center' => 'Center'],
],
];
}
public function compose(array $attributes, string $content, WP_Block $block): array
{
return [
'imageId' => $attributes['imageId'],
'align' => $attributes['align'],
'content' => $content,
];
}
// render() as usual
}{# templates/blocks/hero.twig #}
<section class="hero hero--{{ align }}" style="--hero-image: url('{{ image(imageId).src }}')">
<div class="hero__content">{{ content|raw }}</div>
</section>imageId and align still get sidebar controls, exactly as on a non-container block. innerBlocksTemplateLock accepts the same values as WordPress core's templateLock: 'all' (no insert, move or remove), 'insert' (no insert, but move and remove are allowed), 'contentOnly', or false to explicitly leave it unlocked.
innerBlocksTemplate and innerBlocksTemplateLock are editor-only: they configure the InnerBlocks component and never reach block.json or register_block_type(), unlike allowedBlocks, which WordPress also validates server-side.
Why prose lives in InnerBlocks
Prose lives in InnerBlocks using core blocks. Structured data lives in sidebar controls.
This is a constraint, not a style preference, and it is worth understanding why. The block editor's in-canvas RichText editing — click a heading, type into it — works by editing DOM the editor itself rendered. A Foehn block's canvas is not that: it is ServerSideRender, an iframe-like preview that re-runs the block's own PHP render_callback and Twig template on every keystroke that changes an attribute. There is no editable DOM to click into, because the DOM shown is not the editor's to own — it is the site's actual markup, rendered by the site's own code.
A sidebar TextControl or TextareaControl works fine there because it is a plain form field with no requirement to be the thing on screen. It is an acceptable way to edit a label, a CTA, an alt text, or any short string. It is a poor way to edit a paragraph or a heading, because sidebar fields have no live-preview typing feedback, no formatting, and no place for a second heading and three paragraphs to coexist.
InnerBlocks solves this the way WordPress core already does: hand paragraph- and heading-shaped content to core/paragraph and core/heading, which have their own real, click-to-edit RichText implementations, and let a Foehn block own only the structure and the data around that content — an image, an alignment, a variant, a link target. A hero block is the clearest example: its heading and body are InnerBlocks using core blocks, and its background image and alignment are sidebar controls.
Blocks are dynamic by construction
A Foehn block has no save parameter, and there is no static-block code path anywhere in the framework: #[AsBlock] carries no save, BlockJsonGenerator never emits one, and BlockDiscovery registers every block exclusively through render_callback. This is a guarantee, not an accident of the current implementation.
It is also what makes the rest of this page possible. ServerSideRender can only show a real preview because every block re-renders through PHP on every request, in the editor and on the front end alike. And because there is no serialized markup to keep in sync, a Twig template can be rewritten years from now — new markup, new class names, an entirely different structure — without invalidating a single piece of existing content. The post content stores attributes, not HTML.
Every Foehn block also gets supports.html set to false by default, for the same reason: "Edit as HTML" lets an author hand-edit the serialized block markup, which only makes sense for a block with real saved output to edit. A dynamic block has none, so the option can only ever produce an invalid block. An explicit supports: ['html' => true] on a block still wins if a project genuinely needs it.
Where the registrar comes from
The editor script that reads block schemas and builds sidebar controls is a single generic file, identical for every Foehn project. It is generated into the web root — wp-content/foehn/editor.js — when you run composer install or composer update, the same way Foehn already generates wp-config.php, index.php, and the mu-plugin loader.
This exists because the framework itself lives in vendor/studiometa/foehn/, outside the document root, and cannot be served to a browser directly. Copying it into the web root gives it a real, cacheable URL and genuine line numbers in devtools.
If the file is missing — a fresh clone before its first composer install, or a deployment that skipped it — no Foehn block registers client-side. The block inserter loses every Foehn block, and existing ones on a page show as "unsupported". Foehn does not fail silently here: it logs a message to the browser console explaining that the registrar is missing and that it is generated by composer install, so the symptom points straight at the fix.
No build step, ever
Adding or editing a block never requires a composer install, a JavaScript build, or an npm dependency. The registrar is static and does not change when you add a block; the block definitions it reads — names, titles, attribute schemas, container configuration — come from Foehn's discovery, the same mechanism that already finds your post types and hooks, and refresh on every request in development or whenever the discovery cache is warmed in production.
Write the PHP class, write the Twig template, and the block is authorable. There is nothing to install, nothing to compile, and no package.json involved.
Styling a block
A block's own CSS and JS are named after the block, and are loaded when the files exist:
| File | Loaded |
|---|---|
assets/css/blocks/callout.css | front end and editor |
assets/js/blocks/callout.js | front end, when the block is used, as a type="module" script |
Nothing declares them — theme/callout finds callout.css and callout.js because of their names. Both are attached to the block type, so WordPress loads them only on pages that render the block, and loads the stylesheet into the editor too. That last part is what makes the sidebar worth using: the server-rendered preview is styled exactly like the front end, because it is the same markup with the same stylesheet.
The script is a script module, so import works in it — including @wordpress/interactivity, which WordPress resolves through its import map. These files are served as they are, so they are also outside any build pipeline. Keep them plain CSS and plain JS, and a block stays self-contained — it works in a checkout that has never run npm install. Anything that needs Tailwind or bundling belongs in the theme's main stylesheet instead.