Skip to main content

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.

Two builders, one render path

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:

MethodPurpose
register(string $type, array $config): boolRegister a block (via the flavor_register_block() helper).
unregister(string $type): boolRemove a block by slug. Returns false if it wasn't registered.
get(string $type): ?arrayGet one block's full config (triggers lazy-load of built-ins).
getAll(): arrayAll registered blocks.
getByCategory(string $category): arrayBlocks filtered to one category slug.
getCategories(): arrayThe category map (label + icon per slug).
addCategory(string $slug, array $config): voidAdd a custom category (see Categories).
getBlocksForAdmin(): arrayThe editor-safe schema (drops render_callback) — what the REST/editor surface returns.
search(string $query): arrayMatch 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():

KeyTypeDefaultWhat it does
namestring$typeHuman label shown in the block picker. Wrap in __( …, 'flavor').
descriptionstring''One-line description under the name in the picker.
iconstring'square'Icon slug (Lucide set — see inc/builder/icons.php).
categorystring'content'Category slug the block is grouped under (see Categories).
keywordsstring[][]Extra search terms for the picker's search box.
supportsarray['spacing' => true, 'background' => true, 'animation' => false]Which wrapper-level controls the editor exposes for this block.
settingsarray[]The field schema the editor renders as the block's settings panel (see Settings schema).
defaultsarray[]Default values for every setting id, merged in before your callback runs.
render_callbackcallablenullThe PHP that outputs the block's HTML. Must echo, not return — see below.

type is also a config key but you never set it yourself — register() fills it from the $type argument.

The renderer contract

This is the single most important rule, and the most common mistake:

caution
The render callback must echo, never return

BlockRenderer 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 supports them, the editor's _spacing / _background settings 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:

typeControl
groupA section heading that groups the fields below it (no value of its own).
textSingle-line text input.
textareaMulti-line text.
selectDropdown — provide an options map (value => label).
toggleOn/off boolean.
colorColour picker (Design-System swatches appear automatically).
numberNumeric input.
urlURL input.
imageMedia-library image picker.
iconIcon picker.
cssCustom-CSS editor (use {selector} to target the block).
code / wysiwygRaw code / rich-text editors.
repeaterA repeatable group of sub-fields (e.g. slides, rows).
categories / productsStore-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/color settings, 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):

RouteMethodReturnsPermission
/wp-json/flavor/v1/builder/blocksGET{ blocks: [...], categories: {...} } — the editor-safe schema from getBlocksForAdmin() (no render_callback).edit_posts
/wp-json/flavor/v1/builder/renderPOST{ 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 / POSTLoad / save a page's saved layout.edit_post on {id}
/wp-json/flavor/v1/builder/preview/{id}POSTStash 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

A PHP block does NOT auto-appear in the Visual Builder

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:

  1. Add a components['my-block'] entry in puck-builder/src/config.jsx (or a block module it imports) with Puck fields mirroring your PHP settings, a defaultProps mirroring your defaults, and a React render for the in-editor preview. Add the block's slug to the relevant categories list.
  2. 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 both BuilderAdmin and PuckBuilderAdmin.
  • 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