Back to devlogs
Laravel · Blade · FilamentPHP

Three bugs, one mistake: reading Livewire state instead of Filament's

/ / 5 min read

Rich text saved a TipTap document into the database, file uploads saved an empty array, and then uploads stopped reaching the disk entirely. Three separate-looking bugs, all caused by reading a Livewire property directly instead of letting Filament dehydrate its own form state.

A Filament form field showing "upload complete" beside a page preview showing no image, illustrating state that never reached the database.
On this page

Atelier's page builder keeps a block tree in a Livewire property and builds the settings pane from whichever block is selected. The block declares a Filament schema, the schema becomes the form, and whatever the user types has to end up in the right place in the tree.

The obvious implementation is to read the form state out of the component and write it into the tree:

public function updatedData(): void
{
    $this->tree[$index]['attributes'] = $this->merge(
        $this->tree[$index]['attributes'] ?? [],
        $this->data ?? [],          // ← this line
    );

    $this->persist();
}

That line caused three bugs over two days. They presented completely differently, which is why it took three separate debugging sessions to notice they were the same bug.

Bug one: the rich text field saved a document object

Add a rich text block, type into it, and the public page throws:

Array to string conversion (View: .../blocks/rich-text.blade.php)

The Blade view does {!! $body !!} and $body was an array. Not the per-locale map the tree uses, an actual nested structure:

body[en] = array{type, content}

That is a TipTap document. Filament's RichEditor keeps the editor's own JSON representation in the Livewire property while you are editing, and converts it to HTML on the way out through a state cast. Reading $this->data directly skips the cast, so the editor's internal format went straight into the database, and the view got handed a document tree where it expected a string.

The tell I missed: only some of the eleven sections were broken. The ones I had clicked into.

Bug two: an empty file upload also crashed the page

Then a different section of the same page threw:

Media::url(): Argument #1 ($path) must be of type ?string, array given

FileUpload stores [] when nothing has been chosen, and while editing it holds an array keyed by uuid rather than a path string. My helper had a ?string signature because a stored path is a string, which is true right up until you look at what the field actually holds.

That one has a defensible fix on its own merits, and I made it: Media::url() now takes mixed, unwraps an array, and returns null for anything empty. Every block view goes through that helper, so the unwrapping belongs in one place rather than in nine Blade files.

But fixing it there treated the symptom. The array was in the database because of the same line as bug one.

Bug three: uploads said "upload complete" and saved nothing

This is the one that should have made it obvious.

Upload an image. The field shows a thumbnail and the words "Upload complete". The preview shows "No image chosen". Publish, and the live page shows no image either. The database has [].

The upload genuinely worked. The file was on disk as a Livewire temporary file. It never moved to permanent storage, because Filament does that here:

// vendor/filament/forms/src/Components/BaseFileUpload.php
$this->beforeStateDehydrated(static function (BaseFileUpload $component): void {
    $component->saveUploadedFiles();
});

The file is written to the disk during dehydration. If you never dehydrate, the file never lands, and the field is telling you the truth about the upload while lying about the outcome.

The actual fix

Stop reading the property. Go through the form, in both directions.

Filling, so the casts run inbound:

public function selectBlock(?string $id): void
{
    $this->selectedId = $id;
    $this->rebuildForm();                      // schema depends on the selection
    $this->form->fill($this->flatten($attributes));
}

Reading, so the casts and hooks run outbound:

protected function dehydratedData(): array
{
    // Seed with the raw state, the way getState() does, because
    // dehydrateState() transforms what is already there rather than
    // reading it out of the Livewire component itself.
    $state = ['data' => $this->data ?? []];

    // FileUpload moves the temporary upload onto the disk in this hook.
    $this->form->callBeforeStateDehydrated($state);

    $this->form->dehydrateState($state);
    $this->form->mutateDehydratedState($state);

    return data_get($state, 'data') ?? [];
}

Three things there took reading Filament's source to get right.

dehydrateState() transforms an array that already has values in it; it does not go and fetch state from the component. Called on an empty array it returns nulls, which is what I got on the first attempt and briefly mistook for a different bug.

callBeforeStateDehydrated() is a separate call, and it is where file uploads are written. Skipping it is what made "upload complete" mean nothing.

getState() does all of this and is the documented way to do it, but it validates first. Validating on every keystroke is not what a live preview wants, and a required field would break typing entirely. So this is getState() with the validation removed, which is a slightly uncomfortable thing to write and is why the comment above it names what each step is for.

The two methods have to stay a pair. Filling directly and dehydrating through the form would be just as broken, in the other direction.

Why it took three goes

Each bug had a plausible local explanation.

An array reaching a Blade view looks like bad data, so I fixed the data. A helper with too narrow a type signature looks like a typing mistake, so I widened the type. An upload that does not persist looks like a disk or permissions problem, so I checked storage:link and the filesystem config first.

All three were true observations about the symptom and none of them was the cause. What connects them is that Filament fields keep an internal representation that is not what should be stored, and I was reading that representation and writing it to the database.

Any field with a state cast would have done the same thing. I found three because I happened to use three; the fourth would have been the next one I added.

What I changed besides the code

The renderer now converts rather than trusts. If a value that should be a string arrives as a TipTap document, it renders it to HTML instead of throwing:

if (is_array($value) && isset($value['type'], $value['content'])) {
    return RichContentRenderer::make($value)->toHtml();
}

That is not a fix, the fix is upstream. It is there because a public page must not fatal on data an older version of the package wrote, and there is now a released version out there that could have written it.

And there are tests for each one, including the least obvious: publish a page with an uploaded image and assert the filename appears in the public HTML. That test would have failed on all three bugs.

FAQ

Frequently asked

Because several Filament fields keep an internal working representation in that property which is deliberately not the value you want to store. RichEditor holds a TipTap document while editing and converts it to HTML through a state cast on the way out. FileUpload holds an array keyed by uuid, stores an empty array when nothing is selected, and only writes the temporary file to permanent storage during dehydration. Reading the property directly bypasses all of that, so what lands in the database is the editor's scratch state rather than the stored value, and any side effect that was supposed to happen during dehydration simply does not.

getState is the public path and does everything: it validates, seeds the state from the component, calls the before-dehydrated hooks, dehydrates and mutates. dehydrateState on its own transforms an array you pass in rather than reading state out of the component, so calling it with an empty array returns nulls. callBeforeStateDehydrated is the separate step where side effects run, and it is where FileUpload actually moves the uploaded file onto the disk. If you need dehydrated state without validation, which a live preview does because validating on every keystroke would break typing into a required field, you have to seed the array yourself and call the hook explicitly rather than assuming dehydrateState covers it.

The file is a Livewire temporary upload and nothing has moved it to permanent storage yet. Filament registers a beforeStateDehydrated hook on the file upload component that calls saveUploadedFiles, so the write to disk happens as part of dehydrating the form. Any code path that persists form data without running that hook will show a completed upload in the UI and store an empty value, because the component is honestly reporting that the browser finished uploading while the application never did anything with the result. Check that the save path goes through the form's state rather than reading the Livewire property.

No, and this is why getState is the wrong call in a live-preview editor even though it is the documented one. getState validates before returning state, so a field marked required would throw as soon as the user cleared it to type something new, and the autosave would fail mid-edit. The workable approach is to run the same sequence without the validation step: seed the state, call the before-dehydrated hooks so side effects like file writes still happen, then dehydrate and mutate. Validation belongs on an explicit save or publish action, where failing is meaningful and the user is expecting a result.

Following along? Start a project.

Start a conversation →