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
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 initializationduring 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
\Erroraround 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
nullbut 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 beforeupdateProperty(). - 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 checkFormObjectSynthuses — so uninitialized properties fall through to the normal update path. - No migration is required; only tests asserting the old 500 behavior need adjustment.