Livewire blocks

A dynamic block is a VoodBuilder block whose markup is regenerated by a PHP class instead of being stored as free-form HTML. The editor keeps showing and saving the block, and the published page re-renders it from its configuration. Most dynamic blocks return Blade HTML; a Livewire block goes one step further and returns a mounted Livewire component.

flowchart LR
  A[Plugin defines block class] --> B[Editor sidebar]
  B --> C[Author inserts + configures]
  C --> D[Canvas: static preview]
  C --> E[Published page: live component]

This page is for two different readers:

  • the plugin author / developer who defines a Livewire block (PHP class, Blade partial, registration);
  • the editor / content author who inserts and configures a block that a plugin already provides.

If you only author pages in the canvas, read Inserting and configuring a dynamic block and stop there. Everything else is package code.

Static Tailwind vs dynamic blocks

Static Tailwind block Dynamic block Livewire block
Saved as Page HTML Page HTML + a data-voodbuilder-config payload Page HTML + a data-voodbuilder-config payload
Published markup The saved HTML Re-rendered from Blade on every request A mounted Livewire component
Interactivity None (CSS / front-end JS only) None on its own Live: search, load-more, server-validated forms
Who writes it The author, in the canvas A plugin/developer A plugin/developer
Styled with declarative markers Yes (Declarative fields) Yes, but values land in config Yes, but values land in config

A dynamic block is still a block: it can be moved, ordered and styled at its shell (the section root) like any other section. Its inner markup, however, is regenerated by the block's own Blade, so class tweaks on regenerated inner nodes do not persist — style the shell, and configure content through the block's config.

The two contracts

A Livewire block is a normal server block plus one of two optional contracts.

EditorServerBlock

The base contract for a PHP-rendered block. A class implements:

Method Purpose
getId(): string Stable block id (stored in data-voodbuilder-block)
getLabel(): string Sidebar label
defaultConfig(): array Config the block is inserted with
toHtml(array $config, array $context): string Published markup
toPreviewHtml(array $config, array $context): string Canvas / sidebar preview

EditorLivewireBlock

Extends EditorServerBlock and adds two methods. The published output becomes a mounted component; the canvas still calls toPreviewHtml().

Method Purpose
component(): string Registered Livewire component name (e.g. recent-posts)
propsFromConfig(array $config): array Config → component props (an allowlist)

The allowlist matters: page config is author-controlled, so only the keys propsFromConfig() returns reach the component. Nothing is forwarded wholesale.

EditorConfigurableBlock

Optional, and independent of EditorLivewireBlock — a block can implement both. It adds:

Method Purpose
normalizeConfig(array $config): array Single normalisation point for config

When a block implements it, both the editor preview and the published mount resolve through normalizeConfig(). That is the right place to clamp a page size, reject an empty heading, or coerce types — a hand-edited data-voodbuilder-config on the page cannot push an unbounded value into your query.

Use EditorLivewireBlock alone for a block with fixed behaviour; add EditorConfigurableBlock when the author can configure it.

Who defines, who inserts

Plugin author Editor / content author
Writes code Yes — PHP class, Blade partial, registration No
Adds the block to the sidebar Yes, by registering the class No
Inserts the block on a page — Yes, from the sidebar category
Configures the block Declares the fields Fills the Content panel fields
Can a content author create one alone? — No — a Livewire block has no UI-builder; it must be shipped by a plugin

There is no "new Livewire block" screen in the editor. The plugin author is the only path that produces one; the content author consumes what the plugin registered.

Minimal example

The example below mirrors the working demo block and is verified against the public contracts in 1.11.x. Swap the data source for your own query.

1. The Livewire component

PHP
<?php

declare(strict_types=1);

namespace App\Livewire;

use App\Models\Post;
use Illuminate\Contracts\View\View;
use Livewire\Component;

class RecentPosts extends Component
{
    public string $heading = 'Latest posts';

    public string $search = '';

    public int $perPage = 5;

    public int $limit = 0;

    public function mount(string $heading = 'Latest posts', int $perPage = 5): void
    {
        $this->heading = $heading;
        $this->perPage = max(1, min(20, $perPage));
        $this->limit = $this->perPage;
    }

    public function updatedSearch(): void
    {
        // A new search invalidates what is already loaded.
        $this->limit = $this->perPage;
    }

    public function loadMore(): void
    {
        $this->limit += $this->perPage;
    }

    public function render(): View
    {
        // Your own query goes here.
        $query = Post::query()->latest();

        if (trim($this->search) !== '') {
            $query->where('title', 'like', '%'.trim($this->search).'%');
        }

        $total = (clone $query)->count();

        return view('livewire.recent-posts', [
            'posts' => $query->limit(max(1, $this->limit))->get(),
            'total' => $total,
            'hasMore' => $total > $this->limit,
        ]);
    }
}

The mount() arguments are exactly the props returned by propsFromConfig(), matched by name.

2. One shared Blade partial

The component view and the canvas preview should render the same partial — that is what keeps the compiled Tailwind set covering the mounted markup. The partial branches on a behaviour flag ($live), never on the presence of a control.

BLADE
@php
    $live = $live ?? false;
    $authoring = $authoring ?? false;
    $perPage = $perPage ?? 5;
    $total = $total ?? (is_countable($posts ?? null) ? count($posts) : 0);
    $hasMore = $hasMore ?? false;
@endphp

<section class="rounded-2xl border border-vp-divider bg-vp-bg-elv p-6">
    <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
        <h2
            class="text-xl font-semibold text-vp-text-1"
            @if ($authoring) data-vb-field="heading" data-vb-field-type="text" data-vb-field-label="Heading" @endif
        >{{ $heading }}</h2>

        @if ($authoring)
            <span
                class="sr-only"
                data-vb-field="per_page"
                data-vb-field-type="number"
                data-vb-field-label="Results per page"
            >{{ $perPage }}</span>
        @endif

        <label class="block w-full sm:w-64">
            <span class="sr-only">Search posts</span>
            <input
                type="search"
                @if ($live)
                    wire:model.live.debounce.300ms="search"
                @else
                    disabled
                    aria-disabled="true"
                @endif
                placeholder="Search posts…"
                class="w-full rounded-lg border border-vp-divider bg-vp-bg px-3 py-2 text-sm text-vp-text-1"
            />
        </label>
    </div>

    <ul class="mt-5 grid gap-3 sm:grid-cols-2">
        @forelse ($posts as $post)
            <li class="rounded-xl border border-vp-divider bg-vp-bg p-4">
                <p class="text-sm font-medium text-vp-text-1">{{ $post->title }}</p>
            </li>
        @empty
            <li class="rounded-xl border border-dashed border-vp-divider p-4 text-sm text-vp-text-2">
                No posts found.
            </li>
        @endforelse
    </ul>

    <div class="mt-5 flex items-center justify-between">
        <p class="text-xs text-vp-text-3">{{ $total }} result(s)</p>

        @if ($hasMore)
            @if ($live)
                <button
                    type="button"
                    wire:click="loadMore"
                    class="rounded-lg bg-vp-brand-1 px-3 py-2 text-xs font-semibold text-white"
                >
                    Load more
                </button>
            @else
                <button
                    type="button"
                    disabled
                    aria-disabled="true"
                    data-voodbuilder-skip-cta="true"
                    class="rounded-lg bg-vp-brand-1 px-3 py-2 text-xs font-semibold text-white"
                >
                    Load more
                </button>
            @endif
        @endif
    </div>
</section>

Notes:

  • Emit the data-vb-field markers only in the preview (behind $authoring). The published page does not need editor attributes.
  • data-vb-field-type="number" writes a number; status writes a boolean; everything else writes a string.
  • The preview must render the whole structure, controls included, but inert — see The canvas preview contract.
  • data-voodbuilder-skip-cta="true" (the literal string) stops the editor's CTA scanner from promoting the static button to a link CTA.

3. The block class

PHP
<?php

declare(strict_types=1);

namespace App\Voodbuilder;

use App\Models\Post;
use Voodflow\Voodbuilder\Contracts\EditorConfigurableBlock;
use Voodflow\Voodbuilder\Contracts\EditorLivewireBlock;
use Voodflow\Voodbuilder\Support\Editor\EditorLivewireBlockRenderer;

final class RecentPostsBlock implements EditorConfigurableBlock, EditorLivewireBlock
{
    public static function getId(): string
    {
        return 'recent_posts';
    }

    public static function getLabel(): string
    {
        return 'Recent posts (Livewire)';
    }

    public static function defaultConfig(): array
    {
        return ['heading' => 'Latest posts', 'per_page' => 5];
    }

    public static function normalizeConfig(array $config): array
    {
        $heading = trim((string) ($config['heading'] ?? ''));

        return [
            'heading' => $heading !== '' ? $heading : 'Latest posts',
            'per_page' => max(1, min(20, (int) ($config['per_page'] ?? 5))),
        ];
    }

    public static function component(): string
    {
        return 'recent-posts';
    }

    public static function propsFromConfig(array $config): array
    {
        $config = self::normalizeConfig($config);

        return [
            'heading' => $config['heading'],
            'perPage' => $config['per_page'],
        ];
    }

    public static function toHtml(array $config, array $context): string
    {
        return EditorLivewireBlockRenderer::mount(self::class, $config);
    }

    public static function toPreviewHtml(array $config, array $context): string
    {
        $config = self::normalizeConfig($config !== [] ? $config : self::defaultConfig());

        $query = Post::query()->latest();

        return view('livewire.recent-posts', [
            'heading' => $config['heading'],
            'perPage' => $config['per_page'],
            'posts' => $query->limit($config['per_page'])->get(),
            'total' => (clone $query)->count(),
            'hasMore' => (clone $query)->count() > $config['per_page'],
            // Static render: same structure, inert controls.
            'live' => false,
            'authoring' => true,
        ])->render();
    }
}

4. Register it

Register the Livewire component and the block class in a service provider:

PHP
use App\Livewire\RecentPosts;
use App\Voodbuilder\RecentPostsBlock;
use Livewire\Livewire;
use Voodflow\Voodbuilder\Voodbuilder;

public function boot(): void
{
    Livewire::component('recent-posts', RecentPosts::class);

    Voodbuilder::editorServerBlock('Content', RecentPostsBlock::class);
}

editorServerBlock(string $category, string $blockClass) takes the category first, class second. The block then appears in the editor sidebar under that category.

Guard the registration with interface_exists(EditorLivewireBlock::class) if your app may run against a VoodBuilder release that predates the contract.

Registration is explicit — there is no filesystem auto-discovery of server blocks: a block only exists once a provider calls editorServerBlock().

Inserting and configuring a dynamic block

For a content author, a registered dynamic block behaves like any other block:

  1. Open the page in the visual editor and pick the block from its category in the sidebar.
  2. The canvas drops the block's static preview — the same markup the published page mounts, with interactive controls rendered inert.
  3. Select the block and open the Content panel. Fields the block author declared with data-vb-field appear as controls (text, number, status, …).
  4. Editing a field writes into the block's data-voodbuilder-config and refreshes the preview. The value is saved with the page.
  5. Style the block shell (background, spacing, borders) with the Style panel as usual.

On the published page the same config is passed to the component through propsFromConfig(). Fields only appear if the block author shipped the data-vb-field markers; a block without them is configured by its defaults only.

The canvas preview contract

The canvas renders a static snapshot of the block. The editor never mounts Livewire, so previews must be honest and inert:

Canvas preview Published page
Static markup from toPreviewHtml() The mounted component
No wire:* attributes Live bindings (wire:model, wire:click, …)
Controls disabled / aria-disabled="true" Controls active
Snapshot data from the same query Live data

Rules for a correct preview:

  • Branch on behaviour, not presence: keep the same tags, classes and structure in both branches. Name the flag for what it selects ($live), so "static" can never silently mean "missing".
  • Render real values, not invented ones: if the published component shows a count, run the same count on the initial state.
  • Keep the controls in the preview: the page stylesheet is compiled from the preview markup, so a utility that only exists in the component view would never be generated.

Publishing and wire:ignore.self

On the published page the renderer puts the author's chrome (block identity, id, content-width, data-vb-* paint, Style-panel utilities) on the mount root — the element Livewire owns (wire:id / wire:snapshot).

At every component request Livewire re-renders the component from its own Blade and morphs the response onto that root. The morph removes every attribute the incoming markup does not carry, so the author's width, margin and block identity would disappear at the first interaction (a search keystroke, a load-more click). To prevent that, the renderer stamps wire:ignore.self on the mount root: Livewire then morphs the element's children but leaves its own attributes frozen for the whole session. The component keeps working — only the root attributes are protected.

The block author does not add wire:ignore.self; the renderer applies it automatically to every published mount root. It is never present in the canvas preview.

Editions

The dynamic-block engine — the renderer, the registries and the EditorLivewireBlock / EditorConfigurableBlock contracts — lives in the Core package. As a result:

  • Consuming a Livewire block that a plugin ships (inserting and configuring it) is available in Community, with no capability gate.
  • Defining a Livewire block is a plugin-author action, gated by your package, not by an edition: any package can register a server block.
  • Creating a reusable component from the canvas (the saved section library) is a different feature and is Agency — it ships in the Components companion. See Components and Editions.

A Livewire block provided by a plugin is not an editor-created component: one is code shipped by a package, the other is a saved section authored in the canvas.