ArtisanPack UI
DocsVisual EditorCustom blocks

Custom blocks

Visual Editor v1.18.0

Status: V1 · shipped via issue #346 (A4) Reference block: Host apps and packages register their own blocks under the namespace (or any other custom...

Status: V1 · shipped via issue #346 (A4) Reference block: artisanpack/callout

Host apps and packages register their own blocks under the artisanpack/* namespace (or any other custom namespace) by dropping files into a conventional directory and calling one of three PHP registration methods. The package does the rest: auto-discovery on the JS side, allow-list filtering on the PHP side, and rendering on the public frontend through the three renderer packages (Blade, React, Vue).

This document is the authoring pattern. Forking core blocks is not covered here — that's tracked separately under issue #331 and lands in V2.


1. Directory layout

Custom blocks live under resources/js/visual-editor/blocks/{block-name}/ with this layout:

resources/js/visual-editor/blocks/
└── callout/
    ├── block.json          ← metadata (Gutenberg schema)
    ├── edit.tsx            ← editor-side React component
    ├── save.tsx            ← (static blocks) persisted markup
    ├── index.ts            ← re-exports { metadata, edit, save }
    └── callout.css         ← optional editor styles

The auto-discovery glob keys off index.ts, so every block folder must expose that entrypoint. Vite picks up any import statements in index.ts — CSS, asset references, or additional helpers — as normal.

Dynamic blocks (server-rendered) may omit save.tsx; the PHP render callback produces the public markup instead. See §4.


2. block.json schema

Every block is described by a Gutenberg-compatible block.json. The editor calls @wordpress/blocks.registerBlockType(name, settings) under the hood, so anything valid in the WordPress block.json reference is valid here.

Minimum fields for the ArtisanPack pipeline:

Field Purpose
name namespace/name — lowercase, hyphens only (e.g. artisanpack/callout)
category artisanpack for bundled/reference blocks; free choice for host apps
title User-facing label shown in the inserter
attributes Block attributes (optionally typed/enumerated with default values)
supports Which Gutenberg block supports to enable (alignment, color, spacing, …)

Example (abridged) from artisanpack/callout:

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "artisanpack/callout",
    "title": "Callout",
    "category": "artisanpack",
    "attributes": {
        "severity": {
            "type": "string",
            "enum": ["info", "success", "warning", "error"],
            "default": "info"
        },
        "content": {
            "type": "rich-text",
            "source": "rich-text",
            "selector": "div.ap-callout__body",
            "default": ""
        }
    },
    "supports": {
        "anchor": true,
        "className": true,
        "spacing": { "margin": true, "padding": true }
    }
}

The artisanpack category is registered automatically by the editor; any block whose category matches that slug shows up in the block library sidebar under the ArtisanPack heading.

Every block also gets the visual editor's Effects panel and its artisanpackEffects attribute (see Effects). A block that shouldn't take effects opts out under supports:

{
    "supports": {
        "artisanpackEffects": false
    }
}

3. PHP registration

The PHP VisualEditor::registerBlock() method accepts any of three input shapes. Pick whichever one matches where your metadata lives.

3.1 From a block.json path

use ArtisanPackUI\VisualEditor\Facades\VisualEditor;

public function boot(): void
{
    VisualEditor::registerBlock(
        resource_path('js/visual-editor/blocks/callout/block.json')
    );
}

This is the canonical form — the same file powers both the JS auto- registration and the PHP allow-list.

3.2 From a class

Implement ProvidesBlockMetadata to expose a static blockMetadata() method returning the metadata array. Useful when the metadata is built from config, enum definitions, or other PHP-side state:

use ArtisanPackUI\VisualEditor\Blocks\ProvidesBlockMetadata;
use ArtisanPackUI\VisualEditor\Facades\VisualEditor;

class CalloutBlock implements ProvidesBlockMetadata
{
    public static function blockMetadata(): array
    {
        return [
            'name'       => 'artisanpack/callout',
            'title'      => __('Callout'),
            'category'   => 'artisanpack',
            'attributes' => config('callouts.attributes'),
        ];
    }
}

VisualEditor::registerBlock(CalloutBlock::class);

3.3 From a closure

The most flexible form — handy for quick iteration in a service provider or for blocks assembled at boot time:

VisualEditor::registerBlock(fn (): array => [
    'name'     => 'acme/notice',
    'title'    => __('Notice'),
    'category' => 'artisanpack',
]);

The closure must return an array; a non-array return throws InvalidArgumentException.

3.4 Enabling the block

Registering a block makes it known to the PHP registry; whether the editor exposes it in the inserter depends on the allow-list. Add the fully-qualified block name to config/artisanpack/visual-editor.php:

'enabled_blocks' => [
    // … core blocks …
    'artisanpack/callout',
    'acme/notice',
],

The bundled artisanpack/callout is already in the default allow-list.


4. Static vs dynamic — which do I need?

Static blocks

The edit.tsx + save.tsx pair persists serialized HTML inside the post content. Every renderer (Blade, React, Vue) either outputs that HTML verbatim (when passing through raw) or rebuilds the same markup from attributes. Static blocks are the right default:

  • The output is deterministic from the attributes alone.
  • No request-time data is required to render.
  • The markup does not change based on the viewing user.

The callout is a static block. Its save.tsx returns the final HTML; save/edit parity is validated by Gutenberg on load.

Dynamic blocks

Dynamic blocks render server-side. Register the implementation via VisualEditor::registerDynamicBlock() and omit save.tsx entirely — the editor uses the preview endpoint while authoring, and the Blade/React/Vue renderers call the PHP render callback at request time.

use ArtisanPackUI\VisualEditor\Blocks\DynamicBlock;

class LatestPostsBlock extends DynamicBlock
{
    public function name(): string
    {
        return 'artisanpack/latest-posts';
    }

    public function render(array $attrs)
    {
        return view('blocks.latest-posts', [
            'posts' => Post::latest()->take($attrs['limit'] ?? 5)->get(),
        ]);
    }
}

VisualEditor::registerDynamicBlock(LatestPostsBlock::class);

Use a dynamic block when:

  • Output depends on the current request (authenticated user, request time, query parameters).
  • Output depends on database state that can change between save and render (latest posts, cart totals, stock levels).
  • The block needs to run Laravel authorization before exposing data.

Dynamic block hooks

DynamicBlock exposes seven overridable methods. Only name() and render() are required; the others have safe defaults.

abstract class DynamicBlock
{
    abstract public function name(): string;
    abstract public function render(array $attrs); // : View|Stringable|string

    public function validateAttrs(array $attrs): array
    {
        return $attrs;
    }

    public function searchableText(array $attrs): string
    {
        return '';
    }

    public function authorize(?Authenticatable $user, array $attrs): bool
    {
        return true;
    }

    public function appliesOwnSupports(): bool // since 1.15.0
    {
        return false;
    }

    public function supportAttributes(array $attrs): array // since 1.15.0
    {
        return $attrs;
    }
}
  • validateAttrs(array $attrs): array — runs before render() and before persistence. Normalize, coerce, and reject bad input. Throw InvalidArgumentException to abort. Defaults to passthrough.

    public function validateAttrs(array $attrs): array
    {
        $limit = filter_var($attrs['limit'] ?? 5, FILTER_VALIDATE_INT);
        if ($limit === false || $limit < 1 || $limit > 50) {
            throw new \InvalidArgumentException('limit must be 1–50');
        }
    
        return ['limit' => $limit];
    }
    
  • searchableText(array $attrs): string — plain-text extract used by HasBlockContent::blockContentSearchableText() and the Scout searchable array. Return whatever the block contributes to full-text search. Defaults to empty string.

    public function searchableText(array $attrs): string
    {
        return (string) ($attrs['heading'] ?? '');
    }
    
  • authorize(?Authenticatable $user, array $attrs): bool — gates preview rendering during authoring. Return false to render an authorization-denied placeholder instead of the block. Defaults to true. Public-site renders go through the renderer package directly and run the host app's own authorization.

    public function authorize(?Authenticatable $user, array $attrs): bool
    {
        return $user?->can('view-internal-block') ?? false;
    }
    
  • appliesOwnSupports(): bool (since 1.15.0) — whether render() already applies the block's supports (color, spacing, border, anchor, className, states, responsive, effects, …) to its own wrapper. The default false lets the Blade renderer merge them into the rendered HTML (see §5.4). Return true when the block builds its wrapper with BlockSupports::wrapperAttrs() itself, or renders no wrapper of its own (for example, it only renders an inner block tree), so the supports aren't applied twice or onto the wrong element.

    public function appliesOwnSupports(): bool
    {
        return true;
    }
    

    Closure registrations take the same switch as an option, through registerDynamicBlock() or the $callbacks of registerServerBlock():

    VisualEditor::registerDynamicBlock('acme/notice', [
        'render'             => fn (array $attrs) => view('blocks.notice', $attrs),
        'appliesOwnSupports' => true,
    ]);
    
  • supportAttributes(array $attrs): array (since 1.15.0) — the attribute bag the renderer compiles supports from when appliesOwnSupports() is false. It receives validateAttrs()'s output plus the stored block-support keys the validator didn't return (anchor, className, align, textAlign, style, the color, gradient, font and border presets, layout, responsive, states, artisanpackStates, artisanpackEffects, artisanpackAnimations and animation; the Blade renderer's BlockRenderer::SUPPORT_ATTRIBUTE_KEYS). Any other key the validator drops stays dropped. A support key the validator returns, even as '' or null, wins over the stored value, which is how a block removes one. Override this to drop or rewrite keys the block stores in a shape the supports compiler would misread (for example, a legacy hex value in a palette-slug slot).

Block preview endpoint

While authoring, dynamic blocks render via POST /visual-editor/api/blocks/preview. A single-block request sends { name, attributes, innerBlocks, bindings, context }; the controller resolves the block, runs validateAttrs → authorize → render, and returns HTML.

Since v1.17.0 (#1010), the editor batches these. Previews requested together go out as one request with an items list (at most 25), each item the single-block payload plus a client-chosen key. The response is { "results": [...] } in request order: { key, name, html } on success, or { key, name, error, message, status }, where status is the HTTP status the single-block request would have returned. One failing item doesn't fail the others, and a context record shared by several items is loaded and its view policy checked once.

Identical blocks share one render, and rendered HTML is cached for the editor session, so remounts and undo/redo don't refetch. A block doesn't request its preview until it comes within 400px of the viewport, so a long pattern inserted below the fold renders as it's scrolled into view.


5. Rendering on the public frontend

The visual editor ships three renderer packages. Each one looks up a per-block partial/component by name and falls back to a "no renderer registered" placeholder otherwise. A custom static block needs a matching partial in every renderer you plan to use.

5.1 Blade renderer

artisanpack-ui/visual-editor-renderer-blade resolves the view name visual-editor-renderer-blade::blocks.{namespace}.{block}. Add a partial at:

packages/visual-editor-renderer-blade/resources/views/blocks/artisanpack/callout.blade.php

The partial receives the block attributes in $attributes plus $innerBlocksHtml (pre-rendered children). Mirror the save.tsx markup:

@php
    $severity = (string) ( $attributes['severity'] ?? 'info' );
    $content  = (string) ( $attributes['content'] ?? '' );
@endphp
<div class="ap-callout ap-callout--{{ $severity }}" data-severity="{{ $severity }}">
    <div class="ap-callout__body">{!! $content !!}</div>
</div>

Host apps can override any core or custom partial by publishing visual-editor-blade-views and editing the file under resources/views/vendor/visual-editor-renderer-blade/blocks/….

5.2 React renderer

artisanpack-ui/visual-editor-renderer-react holds a module-level registry keyed by block name. Create a renderer component:

packages/visual-editor-renderer-react/src/blocks/artisanpack/callout.tsx

Then register it from registerCoreBlocks.ts (or from the host app's own bootstrap):

import { registerBlockRenderer } from './registry';
import { CalloutBlock } from './blocks/artisanpack/callout';

registerBlockRenderer('artisanpack/callout', CalloutBlock);

The React component receives { attributes, innerBlocks, children }. Reuse the attrString / classList helpers in support/attributes.ts for safe coercion.

5.3 Vue renderer

Identical pattern to React, using defineComponent and blockRendererProps:

packages/visual-editor-renderer-vue/src/blocks/artisanpack/callout.ts

Register via the same registerBlockRenderer(name, component) API.

5.4 Dynamic blocks and renderers

Dynamic blocks do not need per-renderer partials. The Blade renderer invokes the registered DynamicBlock::render() directly; the React and Vue renderers fall back to <DynamicBlock> which fetches the server- rendered HTML from the preview endpoint.

Since 1.15 (#874) the Blade renderer gives a dynamic block the block supports its block.json declares, the same as a Blade-partial block. Unless the block's appliesOwnSupports() returns true, the output of BlockSupports::compile() (classes, inline styles, anchor, states, responsive and effects scopes, animations) for supportAttributes() is merged into the wrapper element of the rendered HTML.

Which element is the wrapper:

  1. The element carrying a data-ve-wrapper attribute, when the output marks one.
  2. Otherwise the first element, skipping leading whitespace, comments, <style>, <script>, <template>, <noscript>, <link> and <meta>, any leading <svg>, and any leading element with aria-hidden="true" (subtree included). So the classes and anchor id never land on an asset or a decorative icon.

Mark the real wrapper when it isn't the first visible element:

<span class="acme-notice__icon">…</span>
<div class="acme-notice" data-ve-wrapper>
    {{ $message }}
</div>

The merge never overrides the handler's output:

  • classes are appended and deduped;
  • a declaration is added only when the wrapper's style doesn't already set that property, a shorthand that resets it (an existing padding blocks padding-top), or a longhand it would reset (an existing background-color blocks background). Sibling longhands still merge (padding-top doesn't block padding-bottom);
  • id and the animation data-* attributes are added only when absent.

The React and Vue renderers don't merge supports into the HTML the preview endpoint returns; a dynamic block rendered there shows only what render() prints.


6. Registering blocks from another package (no rebuild)

Sections 1–5 describe in-package authoring: the block lives under this package's resources/js/visual-editor/blocks/ and is picked up by the editor build's import.meta.glob. A downstream package — a host app, a module, or a WordPress-style plugin/theme — cannot add to that glob without rebuilding this package's editor bundle. The runtime seam (#766) lets it register a block from outside, two ways.

6.1 Server-driven (PHP only) — the common case

For a dynamic block whose editing needs are covered by attribute controls and a server preview, register everything from PHP with registerServerBlock():

use ArtisanPackUI\VisualEditor\Facades\VisualEditor;

VisualEditor::registerServerBlock(
    'acme/manage-booking',
    [
        'title'      => 'Manage Booking',
        'category'   => 'widgets',
        'icon'       => 'calendar',
        'attributes' => [
            'heading'      => [ 'type' => 'string', 'default' => 'Your booking' ],
            'showCalendar' => [ 'type' => 'boolean', 'default' => true ],
            'columns'      => [
                'type'      => 'number',
                'default'   => 2,
                // Optional per-attribute control override. Without it the
                // control is inferred from `type` (+ `enum`): string → text,
                // boolean → toggle, number → number, enum → select.
                'apControl' => [ 'control' => 'range', 'min' => 1, 'max' => 4 ],
            ],
        ],
    ],
    fn (array $attrs): string => view('blocks.manage-booking', $attrs)->render(),
);

That single call registers the block-type metadata and the server render callback. At boot the editor fetches the server-registered types, and for each one flagged apServerRender (any type that also has a server renderer) with no built-in client module, it synthesizes an edit component: a ServerSideRender canvas preview plus an inspector panel of controls inferred from attributes. No client build, no bundle rebuild.

Escape your attribute output. The render callback's returned HTML is injected as-is into both the editor preview and the public page. Attribute values are author-controlled input, so escape every one you emit (e() in a Blade view, htmlspecialchars() otherwise) — an unescaped attribute is a stored-XSS hole that an editor user could trigger. This is the same contract every dynamic block has always had; it just now applies to blocks a downstream package registers.

Control inference (override any of it with apControl):

attribute type control
string (no enum) text field
string with enum select
boolean toggle
number / integer number (range when min+max are given)

apControl accepts { control, label, help, min, max, step, options }, and apControl: false hides an attribute from the inspector. Content-sourced attributes (those with a source) are always skipped — they are edited in the canvas, not the inspector.

6.2 Client escape hatch — bespoke editor UI

When a block needs a custom edit (a bespoke canvas, richer inspector logic), register it from the host page against the global the bundle installs:

const { element, components, blockEditor } = window.ApVisualEditor.libs;

window.ApVisualEditor.registerBlockType('acme/rating', {
    title: 'Rating',
    category: 'widgets',
    attributes: { stars: { type: 'number', default: 5 } },
    edit: (props) => element.createElement(/* … */),
    // omit `save` for a dynamic block; return the markup for a static one
});
  • Order-independent. Calls made before the editor boots are queued and flushed once the built-in blocks register; calls made afterwards register immediately. Re-registering a name is a no-op.
  • Build against window.ApVisualEditor.libs, not your own copies — element, components, blockEditor, blocks, data, hooks, i18n, and ServerSideRender. Two copies of @wordpress/element or @wordpress/data would each hold their own hook dispatcher / store registry and your controls would render detached from the editor.
  • registerBlocks(modules) takes an array in this package's { metadata, edit, save? } module shape for registering several at once.
  • The site editor mirrors the same surface on window.ApSiteEditor.

7. Authoring checklist

For every new static block:

  • Directory created under resources/js/visual-editor/blocks/{name}/
  • block.json with a namespaced name, category, attributes, supports
  • edit.tsx with useBlockProps and any InspectorControls
  • save.tsx producing byte-identical markup for the edit output
  • index.ts re-exporting { metadata, edit, save }
  • PHP registration (VisualEditor::registerBlock(...) in a boot hook)
  • Added to enabled_blocks in config/artisanpack/visual-editor.php
  • Blade partial in visual-editor-renderer-blade
  • React renderer in visual-editor-renderer-react + registered
  • Vue renderer in visual-editor-renderer-vue + registered
  • Tests:
    • PHP: registry covers at least one of path / class / closure
    • JS: registerCustomBlocks + edit/save round-trip
    • Renderer-Blade / Renderer-React / Renderer-Vue: per-block render test

For dynamic blocks, swap save.tsx for a DynamicBlock subclass and skip the per-renderer partials.