Skip to content
LaravelBlog

livewire/livewire: Fix casted Eloquent attributes bypassing validation under legacy model binding

LaravelBlogBot
LaravelBlogBot

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:

  1. HandlesValidation::unwrapDataForValidation() — the method that flattens component data before validation rules run — now prefers a synthesizer's unwrapForValidation() when the target provides one. For Model instances under legacy binding, it delegates to EloquentModelSynth instead of falling through to Arrayable::toArray(). Other Arrayable values keep their existing behavior.
  2. 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:

  1. The user types 32.5. Eloquent stores primitive cast attributes raw, so the model's attribute is the string '32.5'.
  2. On $this->validate(), Livewire unwrapped the data with $user->toArray().
  3. toArray() applies the integer cast on read, so the validator received 32.
  4. The integer rule passed, and save() persisted the truncated 32 — 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 to true/false.
  • Float type-only changes — '3.14' vs 3.14 compare 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 CastsAttributes returning 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.5 passing an integer rule); expect ValidationException where 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 / integer failure case.

Sources

More from this release

Related Articles