Skip to content
LaravelBlog

livewire/livewire: Fix reactive Eloquent models throws CannotMutateReactivePropException when relations are lazy-loaded

LaravelBlogBot
LaravelBlogBot

Contributed by ghabriel25

Part of the livewire v4.4.7 release

Livewire 4's reactive props feature had a sharp edge: a child component that merely read a relation on a reactive Eloquent model prop — including triggering a lazy load — could crash with CannotMutateReactivePropException, even though it never wrote to the prop. PR #10536 fixes this by changing how reactive props detect mutation. If you're upgrading from v3.8.10, reactive props (#[Reactive]) are entirely new in v4, and this fix makes their mutation guard consistent with how Livewire already treats models everywhere else: identity and attributes matter, loaded relations do not.

What changed

Previously, BaseReactive detected prop mutations with a single hashing strategy at mount, hydrate, and dehydrate:

crc32(json_encode($this->getValue()));

For Eloquent models, json_encode() serializes loaded relations too. This PR replaces those inline hashes with a shared hasher on SupportReactiveProps:

  • Models are hashed by their morph-map-aware class alias, primary key, and getAttributes() — never their loaded relations.
  • Everything else still uses crc32(json_encode($value)), but now returns null when encoding fails instead of silently hashing false (previously, two unencodable values like NAN or INF could collide on the same hash and falsely compare as equal).
  • Hashes are cast to strings, because crc32() can return negative integers on 32-bit PHP builds.

A hashesMatch() helper ties this together and is used when comparing current versus incoming values during hydrate. The update-skip logic (shouldSkipUpdate() / modelMatchesSnapshot()) is unchanged and remains identity-based, matching ModelSynth, which only dehydrates a model's class and key.

Why it matters

Consider the most ordinary render pattern imaginable:

#[Reactive]
public Article $article;

public function render()
{
    return view('...', [
        'author' => $this->article->author->name, // lazy-loads the relation
    ]);
}

In Eloquent, accessing $this->article->author lazy-loads the relation and mutates the in-memory model by attaching it. That mutation had nothing to do with the prop's data — but it changed the json_encode hash, so on dehydrate Livewire threw CannotMutateReactivePropException. In practice this made reactive model props unusable unless you eager-loaded every relation from the parent, and it made mutation detection stricter than the rest of the model pipeline, which deliberately ignores relations when moving models between parent and child.

The new mutation rules

The reactive contract — "a child must not mutate the prop" — is preserved, just scoped to what actually matters:

// ✅ Safe: reading relations (lazy or eager loaded)
$this->article->author->name;

// ❌ Still throws CannotMutateReactivePropException:
$this->article->title = 'Changed';   // attribute write
$this->article = Article::find(2);   // different model identity

You might wonder why attributes are hashed at all instead of relying on isDirty() / wasChanged(). The PR notes explain: a parent may hand the child an already-dirty model, and those flags would false-positive after setValue. Comparing an attribute snapshot from hydrate to dehydrate is what catches real writes without punishing inherited dirty state.

Upgrade impact

If you're coming from v3.8.10, there is nothing to migrate — reactive props are a v4 feature, and you're picking up this fix as part of adopting them. Two practical notes:

  • If you experimented with earlier v4 builds and worked around this bug (eager-loading every relation in the parent, wrapping renders in try/catch, or avoiding model props entirely), those workarounds can be removed.
  • The exception's scope has narrowed: it now fires only on attribute mutations or identity changes. That is almost certainly what you want, but if any of your code depended on the exception firing for relation reads, it won't anymore.

One more subtlety worth knowing: because the model's class is hashed via its morph alias when one is registered, hashes stay stable in applications that use aliased morph maps.

Known limitations

The PR author is explicit that two cases are not fixed, pending a decision on whether they should be supported:

  1. Reactive collections / arrays of models still hash via a full json_encode(), so the original false-positive can still occur when relation reads happen on items inside the collection.
  2. Custom objects implementing JsonSerializable that embed models are also still strict.

If your reactive props are plain models or scalars, you're covered; if they're arrays or wrapped objects, keep eager-loading defensively for now and watch the linked discussions (#6090, #6033) for where this lands.

Takeaways

  • Reading relations on a reactive Eloquent model prop — lazy-loaded or eager-loaded — no longer throws CannotMutateReactivePropException.
  • Attribute mutations and model identity swaps still throw; the "children must not mutate reactive props" contract is intact.
  • Non-model values behave as before, with safer handling of values that fail json_encode() and 32-bit-safe hash casting.
  • Collections/arrays of models and JsonSerializable wrappers remain affected; don't rely on lazy loads inside them yet.
  • No configuration or code changes are required to upgrade — the fix is transparent.

Sources

More from this release

Related Articles