Skip to content
LaravelBlog

livewire/livewire: Don't read uninitialized typed properties when expanding consolidated form object updatesigten 419s, tampered snapshots now fail fast with a 419 the moment they hit a locked property — even if that property has never been assigned a val

LaravelBlogBot
LaravelBlogBot

Contributed by lazerg

Part of the livewire v4.4.7 release

Livewire 4 tightens one step of the request update pipeline: when expanding consolidated form-object updates, Livewire no longer reads component properties that are declared with a type but never assigned. That read used to throw an "accessed before initialization" error, which short-circuited the whole update flow — most importantly, it prevented #[Locked] properties from rejecting tampered values, so those requests died with a 500 instead of the deliberate 419 the lock is designed to produce. If you are upgrading from v3.8.10, there is nothing to migrate here: it is a correctness fix with security-relevant semantics that you mostly benefit from silently. (PR #10687, fixes #10685.)

The bug: reading a property PHP refuses to read

In PHP, a typed property that has been declared but never assigned is not null — it is uninitialized, and any attempt to read it throws an Error. Livewire's dehydration records such a property as null in the component snapshot, so on the next request, hydration leaves it uninitialized all over again.

On every update request, expandConsolidatedFormObjectUpdates() walks the incoming updates. When several updates target a form object's nested data, Livewire consolidates them into a single array under the object's name, and this method re-expands that array back into dotted paths like form.title. To decide whether a property is a form object, it used this guard:

if (is_array($value) && property_exists($component, $path) && $component->$path instanceof Form) {

The trap is that property_exists() returns true for a declared-but-uninitialized typed property — and the very next read, $component->$path instanceof Form, throws. Because the guard ran for any existing property, not just known form objects, any array-shaped update aimed at an uninitialized typed property could crash the request before property syncing even began.

Why locked properties returned 500 instead of 419

This expansion pre-pass runs before updateProperty(), which is where update hooks and attribute enforcement live. #[Locked] is enforced there: when a client sends a value for a locked property, Livewire throws CannotUpdateLockedPropertyException, and the request surfaces as an HTTP 419 — the signal that the snapshot was tampered with.

With the uninitialized-property crash happening first, none of that ran. No update hooks fired, the lock check never executed, and the Error bubbled up as a generic 500. So a tampered update against a locked property that happened to be uninitialized produced a server error instead of the intentional lock response — masking the real cause from you and from any monitoring you have keyed to 419s.

The fix: skip properties that hold no value

The pre-pass now skips uninitialized typed properties, using the same helper that FormObjectSynth already relies on during hydration, so both code paths agree on what "uninitialized" means:

if (is_array($value)
    && property_exists($component, $path)
    && ! Utils::propertyIsTypedAndUninitialized($component, $path)
    && $component->$path instanceof Form
) {

With that guard in place, updates to uninitialized properties fall through to updateProperty(), where hooks, type handling, and locks run in the intended order. A regression test was added covering exactly this case: a #[BaseLocked] typed property that is never assigned, updated with an array, now expects CannotUpdateLockedPropertyException with the message Cannot update locked property: [group] rather than an uninitialized-property error.

What it looks like in your app

use Livewire\Attributes\Locked;
use Livewire\Component;

class GroupSettings extends Component
{
    #[Locked]
    public \stdClass $group; // declared, never assigned

    public function render()
    {
        return view('livewire.group-settings');
    }
}

If a request targets that property — a tampered snapshot from the client, or a stray $component->set('group', ['count' => 1]) in a test:

  • Before: Error: Typed property GroupSettings::$group must not be accessed before initialization → HTTP 500.
  • After: CannotUpdateLockedPropertyException: Cannot update locked property: [group] → HTTP 419.

Once your code actually assigns the property, nothing changes at all; uninitialized state is the only scenario affected.

Upgrading from v3.8.10

  • There are no API, configuration, or template changes. This is a behavior-only fix on the 4.x update pipeline.
  • If you previously saw 500s from Error: ...must not be accessed before initialization during updates, this is that class of problem resolved — locked properties in particular now fail with the proper 419.
  • If you have exception handlers or tests that caught \Error around Livewire updates expecting this failure mode, switch to handling the lock exception (or asserting a 419) instead.
  • Existing tests that asserted a 500 for an uninitialized typed property should be updated to expect CannotUpdateLockedPropertyException / 419.

Takeaways

  • Uninitialized typed properties dehydrate as null but stay uninitialized on the next request, and reading them throws.
  • The old guard (property_exists() + instanceof Form) triggered exactly that read, crashing the expansion pre-pass before updateProperty().
  • That crash bypassed update hooks and #[Locked] enforcement, so tampered locked-property updates returned 500 instead of the intended 419.
  • The pre-pass now uses Utils::propertyIsTypedAndUninitialized() — the same check FormObjectSynth uses — so uninitialized properties fall through to the normal update path.
  • No migration is required; only tests asserting the old 500 behavior need adjustment.

Sources

More from this release

Related Articles

Fix Security 5 mins read

livewire/livewire: Backporting JSON session serialization support to 4.x

Livewire 4.x now understands Laravel's JSON session serialization. Previously, typed `#[Session]` properties such as Collections came back from a full page refresh as plain arrays and failed assignment with a `TypeError`, so the first update after any refresh broke. This backport of #10630 and #10628 introduces a persisted-value codec that restores object types through Livewire's synth system, wrapped in HMAC-signed envelopes so stale, corrupt, or forged values degrade gracefully instead of throwing. Apps on the default PHP serializer see no change.

LaravelBlogBot
LaravelBlogBot