livewire/livewire: Guarding array-shaped synthesizers during property updates
Contributed by calebporzio
Part of the livewire v4.4.7 release
Livewire pull request #10627 fixes a regression introduced in v4.4.1: replacing an array-shaped property value — a Wireable, collection, stdClass instance, form object, or (with legacy support enabled) an Eloquent model — with a plain scalar through a direct property update crashed with a 500 error instead of accepting the new value. The fix introduces ArrayShapedSynth, a small marker interface that tells the update pipeline when a synthesizer should bow out of a replacement. If you are upgrading from v3.8.10, the practical impact is small but worth understanding: the crashing code path never existed in v3, and this patch ensures the v4 pipeline you land on handles these type swaps correctly — including for your own custom synthesizers, if you opt them in.
The bug: swapping an object for a scalar crashed
v4.4.1 taught property updates to recursively hydrate nested synthesized values from the previous checksum-verified snapshot. That is what keeps nested objects stable while a user types into them. The regression was that the previous snapshot's synthesizer metadata was also applied when an update replaced the value with something of a completely different wire shape.
Consider a component holding an array of Wireable objects that gets replaced with plain integers:
$component->set('items', [new EmptyWireable]);
$component->set('items', [1]);
// Expected: [1]
// Before the fix: foreach() argument must be of type array|object, int given
WireableSynth::hydrate() iterates the incoming payload, so receiving an integer threw — in production this surfaced as a 500 at WireableSynth.php:42. The same pattern triggers through $wire.set('items', [1]), wire:model, or entanglement. Until now, the only reliable workaround was performing the type swap inside a server-side action instead of a direct property update.
The fix: an opt-in contract, not new magic
The guard lives in one place, HandleSynths::hydratePropertyUpdate(), and runs after the snapshot metadata is resolved and security-checked:
// Snapshot meta describes the previous value. If an update replaces an
// array-shaped value with a scalar, the previous synth no longer applies.
if ($synth instanceof ArrayShapedSynth && ! is_array($value)) {
return $value;
}
If the resolved synthesizer implements ArrayShapedSynth and the incoming value is not an array, Livewire treats the value as the replacement and never calls hydrate(). The ordering is deliberate and tested: denylisted classes are still rejected by the security policy before this shortcut runs, so a malicious payload cannot smuggle a forbidden class through a scalar replacement. The contract and core direction come from #10611 by @ghabriel25, who is credited as co-author.
What is covered — and what is not
The built-in synths now marked as array-shaped are Array, Collection, Wireable, stdClass, Form objects, and the opt-in legacy Eloquent model and collection synths. Scalar-shaped synths — Carbon, enums, ints, floats, Stringable — are untouched, because their hydrate() methods already accept non-array values.
Two boundaries keep the change narrow. First, it applies only to non-array replacements: an array-shaped synth still receives array updates, even if your application intends that array to mean something else. Second, model synths that reconstruct from metadata keep their existing semantics for array updates. ArraySynth itself has used this same non-array early-return rule since #6190, so this is less a new behavior than a formalization of prior art. A protocol that explicitly distinguishes patch, replace, and remove is a major-version design and deliberately out of scope.
If you ship custom synthesizers
This is the part that matters most when porting code. The contract is opt-in: any synth that does not implement ArrayShapedSynth keeps its previous behavior, including applying old metadata to non-array updates. If your synth always serializes as an array and its hydrate() requires array wire data, mark it:
use Livewire\Mechanisms\HandleComponents\Synthesizers\ArrayShapedSynth;
use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;
class AddressSynth extends Synth implements ArrayShapedSynth
{
// ...
}
From then on, a top-level or nested update that replaces your value with a scalar is accepted as-is. Do not mark synths that hydrate scalars — dates, enums, numbers, strings — as they need their metadata on every update. The new "Array-shaped synthesizers" section of the synthesizer docs covers this in full.
Upgrading from v3.8.10
- Nothing in your v3 application can have depended on the crashing behavior — the recursive update-hydration path arrived with v4.4.1 — so this is a pure improvement on your upgrade path. If you were pinned to 4.4.1 and hit the 500, a release containing #10627 resolves it.
- Direct type swaps via
$wire.set,wire:model, or entanglement now work without routing the change through an action first. - When porting custom synths from v3, audit which ones dehydrate to arrays and add the interface; unmarked ones behave as before, which means they can still throw on non-array replacements.
- The branch passed the full unit suite (1,400 tests, 3,981 assertions) plus new unit, FormObject, legacy-model, and browser tests covering every marked synth.
Takeaways
- v4.4.1 introduced a regression where replacing an array-shaped value with a scalar via a property update threw a 500; #10627 fixes it.
- The mechanism is a marker interface,
ArrayShapedSynth: marked synths decline non-array replacements, and the incoming value is applied directly. - Custom synths must opt in; unmarked synths keep their previous behavior for backwards compatibility.
- The guard runs after the security check and only for non-array values; array updates and scalar-shaped synths are unaffected.
- Coming from v3.8.10 there is no behavioral migration — just mark your own array-shaped synths as you port them.