← Back to Blog

Handling Nullable Strings in Livewire

livewire laravel php

You look at your personal blog and something is wrong. Under the teaser, where a tag belongs, there is a single # with nothing after it.

Only two things were ever supposed to happen here. Either the post has a tag and you see it, or it has none and you see nothing at all. A lonely hash sign was never on the menu.

A teaser for a post rendering a lonely hash sign
A teaser for a post rendering a lonely hash sign

Your first instinct: Maybe this has something to do with the template. But the template is doing what it was told. It checks for null before printing the tag:

@if ($post->tags !== null)
    <p class="mt-3 text-xs text-neutral-400">
        #{{ $post->tags }}
    </p>
@endif

Which means tags is not null. It is something — something that prints as nothing at all. So you open the database.

The Stray # Is Not the Problem

The tags column in a database client: one EMPTY row, two NULL rows, then two rows holding real tags
The tags column in a database client: one EMPTY row, two NULL rows, then two rows holding real tags

Confirmed: EMPTY. Not NULL. Empty.

Why is there an empty string in a nullable column? How many more are there? And how long has this been happening?

This is an especially nasty one, because nothing ever complained, nothing ever threw, and no validation ever failed — nullable is perfectly happy with ''. The write succeeded, the user got their success toast, the row was saved. The stray # is the lucky part of this story. It is the only reason anyone noticed at all.

But the damage is done. Whenever you try to work with the tags column in your queries it won't behave as you expect.

// Includes every row with an empty string
Post::whereNotNull('tags')->get();

You'd think this gives you every post that has a tag. Instead it also hands you every row holding ''. From here it spreads: A unique index lets you store unlimited nulls but rejects the second ''. Anyone joining the project reads ?string $tags and reasonably assumes null is the only empty case.

By the time someone notices, the fix isn't a code change — it's a backfill migration against production data you no longer fully trust. That's the actual cost of this bug, and it's why it's worth understanding rather than patching at the call site.

The Hunt for the Cause

So you go looking. Maybe it's already happening on create.

You open the form object. The property is nullable, typed, and defaults to null. It looks fine:

public ?string $tags = null;

You check the store and update methods. They look fine too — this is about as boring as Laravel gets:

$data = $this->validate();

// in update()
BlogPost::find($id)->update($data);

// in store()
BlogPost::create($data);

Nothing jumps out. There is no ?: '' hiding anywhere, no accessor, no cast. Well, nothing that a simple dd couldn't answer:

$data = $this->validate();

dd($data['tags']);

So you go hunting. A new post with a tag — saved, correct. A new post with the tags field left blank — null, exactly as promised. The bug refuses to show up. Then you open a post that already has a tag, select it, delete it, and save:

dd output showing an empty string returned from validate in BlogForm.php line 50
dd output showing an empty string returned from validate in BlogForm.php line 50

An empty string, handed to you by validate() itself.

But wait — what did you actually do? You cleared a field. That's all. And the field is ?string, defaulting to null, validated as nullable. Every layer you wrote agrees this should be null.

Is this a bug? Is it expected? Was it always like this? So you run the control experiment: build the same field as an ordinary Form Request, post it through a controller, and dump it there.

null.

Same field. Same rule. Same empty input. Two different values. At this point you can stop auditing your own code — the difference isn't in it.

This happened to me on a real client project, and it is that kind of unexpected behavior that is nowhere documented and you only get to know about it when you actively search specifically for that.

Why Laravel Usually Saves You

In a normal Laravel request this never happens, and that's precisely why it's so disorienting.

Laravel ships two global middlewares that run on every HTTP request: TrimStrings and ConvertEmptyStringsToNull. Together they mean that a submitted-but-empty text input arrives in your controller as null, not ''. Most of us have never thought about it. It's simply the water we swim in.

That assumption quietly stops holding the moment the request comes from Livewire.

Why Livewire Doesn't

The instinct is to call this a bug — Livewire missing a middleware. It isn't. Livewire goes out of its way to disable both middlewares, deliberately and by name.

You can read it in Livewire\Mechanisms\HandleRequests\HandleRequests:

function skipRequestPayloadTamperingMiddleware()
{
    \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::skipWhen(function () {
        return $this->isLivewireRequest();
    });

    \Illuminate\Foundation\Http\Middleware\TrimStrings::skipWhen(function () {
        return $this->isLivewireRequest();
    });
}

The method name says the quiet part out loud: from Livewire's perspective, these middlewares tamper with the payload. Note that TrimStrings is switched off too — so a field containing nothing but spaces stays exactly that.

This has been the answer since 2020. When issue #823 asked for the old behavior back, Caleb Porzio closed it:

Caleb Porzio: Not trimming strings and converting to null is the desired behavior of Livewire. You can add traits to bypass this and that's what I would recommend if you want to re-introduce this behavior.
Caleb Porzio: Not trimming strings and converting to null is the desired behavior of Livewire. You can add traits to bypass this and that's what I would recommend if you want to re-introduce this behavior.

Six years and three major versions later, the code above is still there in Livewire 4.

And once you stop thinking of a Livewire request as a form submission, it makes sense. A classic POST is a one-way handoff: the browser throws data at the server and forgets about it, so the server is free to reinterpret that data on the way in. A Livewire request is a state sync. The input holds '', so the component property holds ''. If the server silently rewrote it to null, server state and browser state would disagree on every round trip. Livewire's job is to mirror the input faithfully. Deciding that "empty means nothing" is your application's job, not the transport's.

Which is fine. It just means you have to do it somewhere.

The Hook Everyone Reaches For First

Scroll further down that same issue and you'll find the answer the community settled on — a lifecycle hook, upvoted and copy-pasted ever since:

Community solutions in issue #823: an updated() hook calling data_set to null empty values, and a filter_trim_and_null helper function
Community solutions in issue #823: an updated() hook calling data_set to null empty values, and a filter_trim_and_null helper function

public function updated($name, $value)
{
    if ($value == '') data_set($this, $name, null);
}

It works, it's four lines and it's the snippet you will paste into your third component before you start to resent it.

The obvious cost is repetition: it lives in every component that has a form. Form objects have their own updated() hook, so a component with two of them needs it in both. Every new component is another chance to forget — and, per the section above, forgetting is completely silent. You don't get an error. You get a slightly more inconsistent database.

The subtler cost is that this hook mutates component state, and it does so with no idea which properties are allowed to be null. It sees a name and a value. Give it a typed property that isn't nullable:

public string $search = '';

Clear that input and the hook tries data_set($this, 'search', null):

TypeError: Cannot assign null to property $search of type string

It gets worse, because == is a loose comparison. PHP 8 fixed 0 == ''true in PHP 7, false today. It did not fix false == '', which is still true. So the hook also fires on every unchecked checkbox:

public bool $active = false;    // TypeError, same as above
public ?bool $active = false;   // silently becomes null instead of false

A hook meant to normalize empty strings now has an opinion about your booleans. And the second line doesn't even crash — it just quietly rewrites false to null, which is exactly the class of bug the whole post is about.

Doing It Somewhere Better

The natural place is the boundary you already pass through on the way to the database: validation.

Rather than sprinkling ?: null across every save method — or teaching every component a lifecycle hook — extend Livewire's Form once and normalize the validated data on its way out:

<?php

namespace App\Livewire\Forms;

use Livewire\Form as LivewireForm;

class Form extends LivewireForm
{
    public function validate($rules = null, $messages = [], $attributes = []): array
    {
        $validated = parent::validate($rules, $messages, $attributes);

        return collect($validated)
            ->dot()
            ->transform(fn (mixed $value): mixed => $value === '' ? null : $value)
            ->undot()
            ->toArray();
    }
}

That's the whole thing. === '' is a strict comparison, so it can only ever match a string — no is_string() guard needed, and none of the false == '' trouble from the hook above. Integers, booleans, and empty arrays pass through untouched.

The dot() / undot() pair is what makes it hold up against nested data. dot() flattens ['address' => ['street' => '']] into ['address.street' => ''], so the closure sees every leaf — no recursion to write — and undot() puts the structure back.

The form itself doesn't change at all:

<?php

namespace App\Livewire\Forms;

use Livewire\Attributes\Validate;

class PostForm extends Form
{
    #[Validate('required|string|max:255')]
    public string $title = '';

    #[Validate('nullable|string|max:255')]
    public ?string $tags = null;
}

There is no list of fields to keep in sync, and nothing to opt into. Every string field on every form that extends this class is covered, including ones added next year by someone who never read this post. You can then add trimming to it, if you want, in exactly the same fashion: extending the transform closure to help you with that too.

Clear the tags input now, and validate() hands back null. The template's guard does what it always claimed to do.

Adopting this on an existing form is a one-line change, a simple search and replace operation — the class name doesn't even move:

-use Livewire\Form;
+use App\Livewire\Forms\Form;

 class PostForm extends Form

That's the whole migration. No hook to remember, nothing to add to the component, nothing to add to the next component. A form either extends your base class or it doesn't, and that's visible in the extends clause rather than hidden in a lifecycle method four files away.

Note what this version does not do: it never assigns to a property. public string $search = '' stays a string, an unchecked bool stays false, and the component keeps mirroring the browser exactly as Livewire intends. Only the array on its way to the database gets an opinion about emptiness — which is the one place that opinion belongs.

While you're here, the template deserves a second look. !== null was never really a check for "does this post have a tag" — it's a check for one specific flavor of emptiness, and it quietly bets that no other flavor will ever arrive.

empty() doesn't take that bet:

@if (! empty($post->tags))
    <p class="mt-3 text-xs text-neutral-400">
        #{{ $post->tags }}
    </p>
@endif

For ninety-nine cases out of a hundred, that's the right guard. It covers null, '', and whatever a future refactor decides to put there.

The guard stops the # from rendering today; the base class stops the empty string from being written tomorrow.

The Part That Will Bite You

validate() normalizes the array it returns. It does not touch the form's properties.

After the call, $this->form->tags is still ''. The component is still faithfully mirroring an empty input — which is correct, and which is also a trap:

// Normalized. Empty tags become null.
$this->post->update($this->form->validate());

// NOT normalized. Empty tags stay ''.
$this->form->validate();
$this->post->update($this->form->all());

The second version validates, throws the clean data away, and then reads the raw properties straight off the form. It looks almost identical and reintroduces the exact bug. Use the return value of validate(), always.

Lesson Learned

Every time I learn a new framework/project, I would like to read not only the documentation, but the closed issues on the repo as well. Because some of the knowledge that ends up there never makes it into the documentation — and not knowing it can still bite you.