Skip to content

Livewire Wireable: keep a value object across requests ​

Livewire Wireable is the interface that lets a public property hold your own PHP object instead of an array or a model. You implement two methods, toLivewire() to turn the object into plain data and a static fromLivewire() to build it again, and Livewire calls them between every request. Without it, a typed property holding a custom class fails on the first action with Property type not supported in Livewire for property: [...]. That is the error every interactive section in FilamentCraft threw until v1.3.0, and this post walks through how we fixed it and the choices that went into the payload.

What goes wrong without Wireable ​

Livewire keeps no component in memory between requests. After each render it dehydrates every public property into a JSON snapshot, sends it to the browser, and rebuilds the component from that snapshot on the next request. The properties docs list what it can do that for out of the box: primitives, arrays, backed enums, collections, Eloquent models and collections, dates and Stringable. For anything else you write a Wireable or a Synthesizer.

The mechanism behind that list is a set of property synthesizers. In Livewire 3 (we read 3.7.15), HandleComponents walks the registered synthesizers and asks each one whether it matches the value. If none does, it throws the exception above. Wireable support is itself one of those synthesizers: WireableSynth matches any object that implements Livewire\Wireable.

The object we needed to keep ​

Every section in FilamentCraft receives one argument, a SectionData object. It carries the section's id and type, its settings wrapped in a SettingsValues object that knows the schema and the theme, its blocks, the Site it belongs to, the locale, the page color scheme and a RenderContext with the current route's parameters and bound models.

A LivewireSection, the base class for interactive sections such as the newsletter and contact forms, is a Livewire component, and it stores that object in a public property:

php
abstract class LivewireSection extends Component implements Section
{
    public SectionData $section;

    public function mount(SectionData $section): void
    {
        $this->section = $section;
    }
}

SectionData is a final readonly class, and before v1.3.0 it had no Livewire support, so it matched no synthesizer. The v1.3.0 CHANGELOG entry records the symptom: a LivewireSection could render its first paint but threw "Property type not supported in Livewire" on the first wire action. The nested RenderContext had the same problem, so both classes needed the fix.

The FilamentCraft editor with the Hero section selected, its Content, Call to action, Media, Layout and Style groups in the left panel and the live page on the right
Every value in this settings panel reaches the section through one SectionData object, and an interactive section has to carry it through every request.

Implementing Livewire Wireable on a value object ​

The interface itself is two methods with no types in Livewire's signature, so you can narrow them in your class:

php
namespace Livewire;

interface Wireable
{
    public function toLivewire();

    public static function fromLivewire($value);
}

Here is the toLivewire() half of SectionData, as it ships:

php
public function toLivewire(): array
{
    return [
        'data' => [
            'id' => $this->id,
            'type' => $this->type,
            'settings' => $this->settings->toArray(),
            'blocks' => array_map(
                static fn (BlockData $block): array => [
                    'id' => $block->id,
                    'type' => $block->type,
                    'settings' => $block->settings->toArray(),
                ],
                $this->blocks,
            ),
        ],
        'designMode' => $this->designMode,
        'siteId' => $this->site?->getKey(),
        'locale' => $this->locale,
        'pageColorScheme' => $this->pageColorScheme?->slug,
        'renderContext' => $this->renderContext?->toLivewire(),
    ];
}

Notice what is missing. The settings schema, the theme, the block definitions and the Site model do not travel. The payload carries the raw values an editor saved, plus keys and slugs to find everything else again. The data key has the same shape as the section's entry in the stored page JSON, which is deliberate: it means the rebuild can use the same code path the page renderer uses.

php
public static function fromLivewire(mixed $value): self
{
    $payload = is_array($value) ? $value : [];
    $data = is_array($payload['data'] ?? null) ? $payload['data'] : [];
    $type = is_string($data['type'] ?? null) ? $data['type'] : '';

    $class = app(SectionRegistry::class)->get($type);

    $siteId = $payload['siteId'] ?? null;
    $site = is_int($siteId) || is_string($siteId) ? Site::find($siteId) : null;

    $rawContext = $payload['renderContext'] ?? null;

    return self::fromArray(
        $data,
        $class,
        designMode: (bool) ($payload['designMode'] ?? false),
        site: $site,
        locale: is_string($payload['locale'] ?? null) ? $payload['locale'] : null,
        pageColorScheme: is_string($payload['pageColorScheme'] ?? null) ? $payload['pageColorScheme'] : null,
        context: $rawContext !== null ? RenderContext::fromLivewire($rawContext) : null,
    );
}

SectionData::fromArray() is the factory the renderer already calls for every section on every page. It looks the section class up by type in the registry, compiles the settings schema against the site's theme and wraps the values. Reusing it means a section on its tenth request has exactly the settings object it had on the first one, including theme-aware defaults.

Send keys, not models ​

You might expect to put the Site model itself in the array. WireableSynth dehydrates each child of the array you return, so a model child would go through Livewire's ModelSynth and come back on hydrate. We sent the key instead, for two reasons.

ModelSynth::hydrate() restores the model with firstOrFail(). If the site is deleted while a visitor has the page open, their next click becomes a 404 from inside Livewire's hydration. Site::find() returns null, and SectionData already accepts a null site (the settings then compile without a theme), so the section keeps rendering.

The second reason is that a flat array of scalars is the whole contract. Reading toLivewire() tells you exactly what ends up in the page's HTML snapshot, with nothing hidden inside another synthesizer's metadata.

RenderContext makes the same choice for route bindings. On a product page the context holds the bound product model, but it can hold any object a host binds. Only Eloquent models survive:

php
foreach ($this->bindings as $key => $binding) {
    if ($binding instanceof Model) {
        $bindings[$key] = [
            'morph' => $binding->getMorphClass(),
            'key' => $binding->getKey(),
        ];
    }
}

Each model travels as its morph alias and key. On the way back, RenderContext::fromLivewire() resolves the alias with Relation::getMorphedModel(), checks the result is a model subclass, and calls find(), skipping anything that no longer exists. A binding that is not a model cannot be rebuilt safely from JSON, so it is dropped rather than guessed at.

Is the payload safe to trust ​

A reasonable worry is that siteId sits in the browser, so a visitor could edit it and read another tenant's site. Livewire 3 covers this: HandleComponents calls Checksum::verify() on every incoming snapshot before hydrating it, and the checksum is an HMAC over the snapshot keyed with your app key. An edited siteId fails verification before fromLivewire() runs.

What the checksum does not do is hide anything. The snapshot is readable in the page source, so never put a secret into toLivewire(). Settings values, ids and slugs are fine; an API token is not.

Wireable or Synthesizer ​

Both solve the same problem. A Wireable puts the conversion on the class, which is right when you own the class and it only needs to survive as a whole. A Synthesizer is registered globally with Livewire::propertySynthesizer() and can also support wire:model into nested keys through its get() and set() methods, which is the better fit for a type you do not own or want bound to inputs.

SectionData is read-only from the section's point of view: the visitor types into ordinary public properties such as $email, never into the settings. A Wireable was the smaller change and keeps the serialization next to the constructor it has to mirror.

Testing the round-trip ​

The test for this should call an action, so the object makes a full trip through the snapshot and back. FilamentCraft's SectionDataFactory builds the object, and ->test() mounts the section with it:

php
SectionDataFactory::for(NewsletterSection::class)
    ->settings(['heading' => 'Join'])
    ->test()
    ->set('email', 'a@b.test')
    ->call('subscribe')
    ->assertSet('subscribed', true);

The testing reference has the full recipe, including an assertion that $section still renders its heading after the action. The custom sections guide covers the rest of writing an interactive section, and the live demo has the editor to try it in.

Last updated: