livewire/livewire: Avoid unnecessary validation data unwrapping
Contributed by calebporzio
Part of the livewire v4.4.7 release
Livewire's validation layer has always done a little extra work before handing your component's data to Laravel's validator: every top-level public property gets "unwrapped" through the synthesizer registry so objects like Collections, Wireables, and Eloquent models become plain, validation-ready arrays. PR #10620 adds a two-line guard so scalars and arrays — values Laravel can already validate as-is — skip that unwrapping entirely. If you're upgrading from v3.8.10, this lands on the 4.x line: your validation results won't change, large scalar-heavy forms validate dramatically faster, and a rare, confusing fatal error on string properties disappears.
What changed
Before this PR, unwrapDataForValidation() pushed every top-level property through findSynth() — strings, integers, booleans, nulls, and arrays included. No built-in synth matches a non-object value, so each of those lookups was pure overhead. The fix bails out early for anything that isn't an object:
protected function unwrapDataForValidation($data)
{
return collect($data)->map(function ($value) {
// Scalars and arrays are already valid Laravel validation data...
if (! is_object($value)) return $value;
$synth = app('livewire')->findSynth($value, $this);
// ...objects continue through unwrapForValidation()/toArray() as before
});
}
Objects take exactly the same path as before, so validation behavior for Collections, Wireables, models, and custom synths is byte-for-byte identical.
Why it matters
For string values, findSynth() routes through findByKey(), costing roughly 15µs per property per call. That's invisible on a two-field form, but it compounds with the pattern most of us actually use: wire:model.live.blur firing validateOnly() on every keystroke. A 38-property form paid thousands of wasted synth lookups just to validate one field, and this was the dominant cost in the reported slow validateOnly() issues (#9416, #10565).
The measurements in the PR are dramatic:
- 10,000 preparation passes over 38 scalar fields: 5,385ms → 17ms
- A real browser round trip (
wire:model.live.blur→updated()→validateOnly()) on a 16-property component: 1,000 unwrap passes dropped 624ms → 1.1ms
The typical beneficiary looks like this:
class ProfileForm extends Component
{
#[Validate('required|string|min:2')]
public string $name = '';
#[Validate('required|email')]
public string $email = '';
// ...three dozen more scalar properties
public function updated($property)
{
$this->validateOnly($property);
}
}
Every keystroke on a component like this now skips 30+ wasted synth lookups.
The rare fatal it also fixes
The old path had a sharper edge, too: findSynth() treats a string value as a key and tries findByKey(). If a string property's value happened to match a synth key — for example, the Wireable synth's key 'wrbl' — the string was resolved to that synth and validation prep fataled calling ->toLivewire() on a string:
public string $title = 'wrbl'; // fine on any other string, fatal on this one
public function save()
{
$this->validate(['title' => 'required|string|min:3']);
}
With the non-object bail, this now validates normally. It's an edge case, but exactly the kind of bug that costs an afternoon to diagnose.
Dependent rules still see unwrapped siblings
The PR also explored a more aggressive optimization — keeping Arrayable/ArrayAccess siblings lazy during validateOnly() — and deliberately rejected it. Dependent rules that reference another property need that property fully unwrapped:
$this->validateOnly('chosen', [
'chosen' => 'in_array:options.*.id', // wildcard params via Arr::dot()
]);
$this->validateOnly('copy', [
'copy' => 'same:original', // whole-Collection comparison
]);
$this->validateOnly('reason', [
'reason' => 'required_if:article.status,published', // enum-cast model
]);
Making siblings lazy broke these silently: wildcard parameters never match objects through Arr::dot(), whole-object same:/gt: comparisons fail on type mismatches, and model siblings would leak live cast/hidden/lazy-load semantics into validation. Guarding every case requires a rule-parameter-scanning heuristic that isn't worth its weight, so siblings stay eagerly unwrapped — and a new suite of tests pins that behavior (wildcard in_array, whole-Collection same:, enum-cast required_if, withValidator() + sometimes(), and a negative-path dependent rule).
Upgrade impact
Practically none:
- No public API or validation-rule behavior changed, and no documentation update is needed.
- Validation results are identical for everything object-shaped; the only observable difference is the
'wrbl'-style fatal becoming a passing validation. - Custom synth authors: scalars and arrays simply never reach
findSynth()during validation prep. No built-in synth ever matched non-objects, so object synths are unaffected. - The PR adds a synth-spy test proving scalars skip
findSynth(), regression tests for every dependent-rule shape above, and the full unit suite passes — 1,432 tests, 4,068 assertions — merged with the current 4.x head.
Takeaways:
- Two-line guard in
unwrapDataForValidation(): non-objects skip the synth lookup entirely. - Massive
validateOnly()speedup for scalar-heavy forms: 5,385ms → 17ms over 10,000 prep passes; 624ms → 1.1ms in a real browser round-trip benchmark. - Dependent rules against object siblings (
in_array:options.*.id,same:original,required_if:article.status) behave exactly as before. - String values that happen to match a synth key no longer fatal during validation.
- Nothing to change in your code when moving from v3.8.10 to 4.x.