livewire/livewire: Fix casted Eloquent attributes bypassing validation under legacy model binding
Contributed by ghabriel25
Part of the livewire v4.4.7 release
Livewire 4.x — with a backport to 3.x — fixes a validation loophole that let invalid user input slip past rules when validating nested Eloquent model properties. Under legacy model binding, Livewire prepared validation data by calling Model::toArray() on the bound model, and toArray() applies attribute casts. That meant user input was silently mutated before the validator ran: 32.5 typed into an integer-cast field arrived at the validator as 32, so the integer rule incorrectly passed. This release adds an unwrapForValidation() hook to EloquentModelSynth, which restores the raw, user-submitted values whenever they differ from their casted form. It fixes discussion #3400 and ships with a browser test covering the exact scenario.
What changed
Two pieces changed:
HandlesValidation::unwrapDataForValidation()— the method that flattens component data before validation rules run — now prefers a synthesizer'sunwrapForValidation()when the target provides one. ForModelinstances under legacy binding, it delegates toEloquentModelSynthinstead of falling through toArrayable::toArray(). OtherArrayablevalues keep their existing behavior.EloquentModelSynth::unwrapForValidation()compares each raw attribute against its casted counterpart and restores the raw value when both are scalar and loosely differ:
public function unwrapForValidation($model)
{
$values = $model->toArray();
foreach ($model->getAttributes() as $key => $raw) {
$casted = $values[$key] ?? null;
if (is_scalar($raw) && is_scalar($casted) && $raw != $casted) {
$values[$key] = $raw;
}
}
return $values;
}
The guard is deliberately conservative: only scalar raw/casted pairs are swapped, so object-returning custom casts (dates, enums, class casts) keep their casted form — mirroring how the synth already dehydrates those values.
Why it mattered
Consider this component with legacy_model_binding enabled and User casting age to integer:
class SaveProfile extends Component
{
public User $user;
protected function rules(): array
{
return ['user.age' => 'required|integer'];
}
public function save()
{
$this->validate(); // previously passed with invalid input
$this->user->save();
}
}
<div>
<input wire:model="user.age" />
<button wire:click="save">Save</button>
@error('user.age') <p>{{ $message }}</p> @enderror
</div>
The old flow broke down like this:
- The user types
32.5. Eloquent stores primitive cast attributes raw, so the model's attribute is the string'32.5'. - On
$this->validate(), Livewire unwrapped the data with$user->toArray(). toArray()applies theintegercast on read, so the validator received32.- The
integerrule passed, andsave()persisted the truncated32— no error, silently wrong data.
How the fix works
With this release, the same component now behaves correctly. When validation data is unwrapped, the synthesizer compares the raw attribute ('32.5') against the casted value (32), finds they differ, and restores the raw value. The validator receives age => '32.5', fails the integer rule, and the user sees "The age field must be an integer."
The new ModelValidationBrowserTest locks this in: it types 32.5 into the field, clicks save, and asserts the validation message renders — the exact sequence that used to pass validation.
Edge cases and limitations
The raw-vs-casted comparison can't restore every value, by design:
- Boolean-ish strings — values like
'yes','true', or''may not be restored, because PHP's loose!=treats them as equal totrue/false. - Float type-only changes —
'3.14'vs3.14compare loosely equal, so the casted value is kept. This is usually harmless for numeric rules. - Dates — restored only when the raw string differs from the serialized, casted string.
- Custom
CastsAttributesreturning objects —is_scalar()fails, so the casted form is kept during validation. - Rules expecting post-cast values — anything written against the already-cast representation (e.g. an already-rounded decimal) will now see the pre-cast input instead.
Upgrade impact
If you're upgrading from v3.8.10, you'll pick this up in the next 3.x patch release (it's also in 4.x), and it's a drop-in upgrade: no configuration or API changes are required.
The visible change is stricter validation. Inputs that previously slipped through casted-attribute rules will now throw a ValidationException and surface through @error — which is the point of the fix, but it can flip previously "passing" forms into failing ones. If you intentionally relied on casted values in a rule, either normalize the input on the client, cast explicitly inside the rule, or validate against a plain array rather than the model. If you maintain a custom property synthesizer, implementing unwrapForValidation() lets your types opt into the same raw-value behavior.
Takeaways
- Validation data for legacy-bound Eloquent attributes now reflects the raw user input, not casted output.
- Primitive casts can no longer mask invalid input (e.g.
32.5passing anintegerrule); expectValidationExceptionwhere invalid data used to pass silently. - No configuration changes are needed; the behavior applies automatically when model properties are used with legacy binding.
- Object-returning custom casts and certain loose-comparison edge cases (booleans, identical floats) keep their casted values during validation.
- A regression is covered by a new browser test reproducing the
32.5/integerfailure case.