Creating a Page-Builder Block
The Flavor Starter theme ships a page builder with 45+ built-in blocks. Every block is a small PHP definition: a config array (what the block is called, its category, and the settings the editor should expose) plus a render callback (the PHP that produces the block's front-end HTML). You register a block with a single helper — flavor_register_block() — and it becomes available in the builder.
This page shows how to register your own block end-to-end, the exact config schema, the renderer contract (including the one gotcha that bites everyone), and how a PHP-registered block surfaces in the theme's two editors.
The theme has two page editors (see Visual Page Builder for the customer view): the Classic Builder (panel-based) and the newer Visual Builder (Puck, drag-and-drop). They both save the same layout format and both render on the front end through the same PHP render callback you write here. The difference is the editing surface — covered in Editor integration below.
The registration API
Two global helpers live in the Flavor\Builder namespace (flavor-starter/inc/builder/BlockRegistry.php):
namespace Flavor\Builder;
// Register a block. Returns false if $type is already registered (no clobber).
function flavor_register_block(string $type, array $config): bool;
// Get the registry singleton (for addCategory(), search(), getByCategory(), …).
function flavor_blocks(): BlockRegistry;
From outside the Flavor\Builder namespace, call them fully-qualified: \Flavor\Builder\flavor_register_block( … ).
$type is the block's unique machine slug (for example alert, divider, my-cta). If a block with that slug already exists, register() returns false and does not overwrite it — built-in blocks always win over a later duplicate registration.
The registry (BlockRegistry) is a singleton exposing:
| Method | Purpose |
|---|---|
register(string $type, array $config): bool | Register a block (via the flavor_register_block() helper). |
unregister(string $type): bool | Remove a block by slug. Returns false if it wasn't registered. |
get(string $type): ?array | Get one block's full config (triggers lazy-load of built-ins). |
getAll(): array | All registered blocks. |
getByCategory(string $category): array | Blocks filtered to one category slug. |
getCategories(): array | The category map (label + icon per slug). |
addCategory(string $slug, array $config): void | Add a custom category (see Categories). |
getBlocksForAdmin(): array | The editor-safe schema (drops render_callback) — what the REST/editor surface returns. |
search(string $query): array | Match blocks by name or keyword. |
The $config schema
Every key is optional except that a block without a render_callback renders nothing on the front end. Defaults are applied by BlockRegistry::register():
| Key | Type | Default | What it does |
|---|---|---|---|
name | string | $type | Human label shown in the block picker. Wrap in __( …, 'flavor'). |
description | string | '' | One-line description under the name in the picker. |
icon | string | 'square' | Icon slug (Lucide set — see inc/builder/icons.php). |
category | string | 'content' | Category slug the block is grouped under (see Categories). |
keywords | string[] | [] | Extra search terms for the picker's search box. |
supports | array | ['spacing' => true, 'background' => true, 'animation' => false] | Which wrapper-level controls the editor exposes for this block. |
settings | array | [] | The field schema the editor renders as the block's settings panel (see Settings schema). |
defaults | array | [] | Default values for every setting id, merged in before your callback runs. |
render_callback | callable | null | The PHP that outputs the block's HTML. Must echo, not return — see below. |
typeis also a config key but you never set it yourself —register()fills it from the$typeargument.
The renderer contract
This is the single most important rule, and the most common mistake:
echo, never returnBlockRenderer runs your callback inside an output buffer — it calls ob_start(), invokes your callback, then captures ob_get_clean() (inc/builder/BlockRenderer.php, around L131). Anything you return from the callback is discarded. Everything you want on the page must be printed (echo, printf, or dropping out of PHP into raw HTML with ?> … <?php).
Signature and behaviour:
'render_callback' => function (array $settings, array $blockData = []): void {
// $settings — your block's saved settings, already merged over your `defaults`.
// $blockData — the raw block node ({ id, type, settings }) if you need the block id.
// ECHO your markup. Do not return it.
}
What BlockRenderer does for you — so you don't hand-roll it in every block:
- Wrapper element. Your output is wrapped in
<section id="…" class="flavor-block flavor-block--{type} …">. Don't emit your own outer<section>— emit the inner content (a<div>is fine). - Spacing & background. If your block
supportsthem, the editor's_spacing/_backgroundsettings are turned into wrapper classes/styles automatically. - Animation, hover, scroll, responsive-hide, conditional display, element order. All handled at the wrapper level from the shared setting groups (see below) — you don't render these yourself. A block hidden by a display condition is skipped entirely before your callback's output is used.
- Dynamic tags. Your rendered HTML is passed through
flavor_process_dynamic_tags()so tokens like dynamic field values resolve. - Escaping is still yours. The wrapper is escaped, but everything inside your callback is your responsibility — use
esc_html(),esc_attr(),esc_url(),wp_kses_post()on any user-supplied value.
Settings schema
settings is an ordered array of field definitions. The editor renders each one as a control in the block's settings panel; getBlocksForAdmin() ships this schema to the browser (the render_callback is stripped — it never leaves the server). Each field is ['id' => …, 'type' => …, 'label' => …, …].
Field type values used across the built-in blocks:
type | Control |
|---|---|
group | A section heading that groups the fields below it (no value of its own). |
text | Single-line text input. |
textarea | Multi-line text. |
select | Dropdown — provide an options map (value => label). |
toggle | On/off boolean. |
color | Colour picker (Design-System swatches appear automatically). |
number | Numeric input. |
url | URL input. |
image | Media-library image picker. |
icon | Icon picker. |
css | Custom-CSS editor (use {selector} to target the block). |
code / wysiwyg | Raw code / rich-text editors. |
repeater | A repeatable group of sub-fields (e.g. slides, rows). |
categories / products | Store-aware pickers (need the eCommerce plugin). |
Useful per-field extras: description (help text), options (for select), and condition (['otherFieldId' => 'value'], or '!value' to show unless equal) to show a field only when another field has a given value.
Reuse the shared setting groups
Don't re-declare animation, responsive-visibility, conditional-display, or custom-CSS fields by hand. inc/builder/blocks/_block-helpers.php exposes ready-made groups you merge into your settings and defaults:
use function Flavor\Builder\Blocks\get_responsive_settings; // + animation, hover, scroll, conditional, visibility
use function Flavor\Builder\Blocks\get_responsive_defaults;
use function Flavor\Builder\Blocks\get_css_settings; // custom class + custom CSS
use function Flavor\Builder\Blocks\get_css_defaults;
'settings' => array_merge([ /* your fields */ ], get_responsive_settings(), get_css_settings()),
'defaults' => array_merge([ /* your defaults */ ], get_responsive_defaults(), get_css_defaults()),
Categories
The registry defines five built-in category slugs (BlockRegistry::$categories): layout, hero, products, marketing, content. Set your block's category to one of these so it groups correctly in the picker.
To add your own category, call addCategory() on the registry before the editor reads the block list:
\Flavor\Builder\flavor_blocks()->addCategory('my-group', [
'label' => __('My Group', 'flavor'),
'icon' => 'sparkles', // Lucide slug; defaults to 'folder'
]);
Then register blocks with 'category' => 'my-group'.
A complete worked example
A minimal "Highlight" block — a coloured box with a title and body. Drop this in a file and register it (placement options in the next section).
<?php
namespace Flavor\Builder\Blocks;
defined('ABSPATH') || exit;
use function Flavor\Builder\flavor_register_block;
flavor_register_block('highlight', [
'name' => __('Highlight', 'flavor'),
'description' => __('A coloured box with a title and short text', 'flavor'),
'icon' => 'star',
'category' => 'content',
'keywords' => ['highlight', 'callout', 'box'],
'supports' => ['spacing' => true, 'background' => false],
'settings' => array_merge([
[
'id' => 'contentGroup',
'type' => 'group',
'label' => __('Content', 'flavor'),
],
[
'id' => 'title',
'type' => 'text',
'label' => __('Title', 'flavor'),
],
[
'id' => 'body',
'type' => 'textarea',
'label' => __('Body', 'flavor'),
],
[
'id' => 'accent',
'type' => 'color',
'label' => __('Accent Colour', 'flavor'),
],
], get_responsive_settings(), get_css_settings()),
'defaults' => array_merge([
'title' => __('Did you know?', 'flavor'),
'body' => __('Add your highlight text here.', 'flavor'),
'accent' => '#6366f1',
], get_responsive_defaults(), get_css_defaults()),
'render_callback' => function (array $settings): void {
$title = $settings['title'] ?? '';
$body = $settings['body'] ?? '';
$accent = $settings['accent'] ?? '#6366f1';
?>
<div class="flavor-highlight" style="
border-left: 4px solid <?php echo esc_attr($accent); ?>;
background: #f9fafb; padding: 16px 20px; border-radius: 0 8px 8px 0;">
<?php if ($title): ?>
<strong style="display:block; margin-bottom:4px;">
<?php echo esc_html($title); ?>
</strong>
<?php endif; ?>
<div><?php echo wp_kses_post(nl2br($body)); ?></div>
</div>
<?php
// Note: no return — the markup above is echoed and captured by BlockRenderer.
},
]);
The two shortest built-in blocks to read as canonical references:
inc/builder/blocks/divider.php— a pure-presentational block:select/colorsettings, no dynamic data.inc/builder/blocks/alert.php— settings groups,toggles, conditional styling, escaping, and a{selector}custom-CSS block.
Where to put the registration
First-party (inside the theme). Drop a file in flavor-starter/inc/builder/blocks/. On first use the registry globs inc/builder/blocks/*.php and require_onces every file — your file loads and self-registers with no wiring. This is how all 36 built-in renderer files work. Files are loaded alphabetically, so _block-helpers.php (underscore prefix) loads first and its helpers are available to the rest.
Extension (child theme or companion plugin). There is no dedicated do_action('flavor_register_blocks') hook — the built-ins load by glob, not by an action. Register on an early hook so it runs before the editor or a front-end template reads the registry. after_setup_theme or init are both safe (the built-ins lazy-load later, on the first get()/getAll()):
add_action('after_setup_theme', function () {
if (function_exists('Flavor\\Builder\\flavor_register_block')) {
\Flavor\Builder\flavor_register_block('my-cta', [ /* config */ ]);
}
}, 11);
The function_exists() guard keeps your plugin safe if the Flavor theme is ever inactive.
Editor integration
REST / editor surface
The builder exposes these routes (registered in BuilderAdmin::registerRestRoutes(), all under flavor/v1):
| Route | Method | Returns | Permission |
|---|---|---|---|
/wp-json/flavor/v1/builder/blocks | GET | { blocks: [...], categories: {...} } — the editor-safe schema from getBlocksForAdmin() (no render_callback). | edit_posts |
/wp-json/flavor/v1/builder/render | POST | { success, html } — server-side render of a single block node { type, settings, id } via your PHP render_callback. | edit_posts |
/wp-json/flavor/v1/builder/layout/{id} | GET / POST | Load / save a page's saved layout. | edit_post on {id} |
/wp-json/flavor/v1/builder/preview/{id} | POST | Stash unsaved layout in a 60-second transient for live preview. | edit_post on {id} |
Because the front-end and the Classic preview both render through /builder/render → your PHP callback, the live output and the editor preview are guaranteed identical for a PHP-registered block. There is no separate front-end JS to write.
Classic Builder — automatic
The Classic Builder is data-driven from the registry. On the page-edit screen, BuilderAdmin::enqueueAssets() localizes getBlocksForAdmin() into the editor (flavorBuilder.blocks), which builds the block picker and the settings panel from your settings schema; the live preview comes from /builder/render. So a PHP-registered block appears in the Classic Builder automatically — no JavaScript required.
Visual (Puck) Builder — needs a matching JS component
The Visual (Puck) builder does not read the PHP registry. Its palette and in-editor preview come from a separate, hand-authored JavaScript config (flavor-starter/puck-builder/src/config.jsx plus the block modules it merges). The Puck app never calls /builder/blocks or /builder/render — it uses static JS component definitions and fetches only the saved layout over REST. (Verified: no reference to those endpoints exists anywhere in puck-builder/src/.)
Consequence: a newly PHP-registered block renders correctly on the live site (both builders save the same layout and the front end always renders via your PHP callback), but it will not show up in the Visual Builder's picker until you add a matching Puck component and rebuild the bundle.
To make a custom block editable in the Visual Builder as well, you (roughly) add a component definition to the Puck config and rebuild:
- Add a
components['my-block']entry inpuck-builder/src/config.jsx(or a block module it imports) with Puckfieldsmirroring your PHPsettings, adefaultPropsmirroring yourdefaults, and a Reactrenderfor the in-editor preview. Add the block's slug to the relevantcategorieslist. - Rebuild the Puck bundle to
puck-builder/dist/.
[VERIFY] The exact Puck build command and any field-mapping conventions — confirm against flavor-starter/puck-builder/package.json (a Vite app: build script = vite build) and the existing entries in config.jsx before documenting the steps as canonical. On Windows, invoke Vite directly (node ./node_modules/vite/bin/vite.js build) rather than via npm run when the project path contains &.
Other extension points
flavor_builder_post_types(filter) — array of post types the builder attaches to. Defaults to['page']; add your own to enable the builder on custom post types. Applied in bothBuilderAdminandPuckBuilderAdmin.flavor_builder_register_dynamic_tags(action) — fires with the dynamic-tags registry so you can register custom dynamic tags (inc/builder/DynamicTags.php).
Front-end rendering, in brief
You rarely call the renderer yourself — the builder wires it up: PageBuilder::filterContent() replaces the_content with the rendered layout on pages that have the builder enabled. If you need to render a layout manually (e.g. a custom template), the helpers in BlockRenderer.php are available:
echo \Flavor\Builder\flavor_render_builder_content($postId); // renders a post's saved builder layout
echo \Flavor\Builder\flavor_render_block($blockNode); // renders a single block node