laravel/framework: JSON path updates no longer discard unsaved changes on cast objects
Contributed by xurshudyan
Part of the framework v13.35.0 release
Pull request #61847 fixes a quiet but nasty data-loss bug in Eloquent's JSON path handling. When an attribute is cast to an object — via AsArrayObject, AsCollection, AsFluent, or a custom object cast — and you both mutate that object in memory and write a JSON path on the same attribute (the settings->locale style), the path write silently discarded your unsaved mutations. The fix syncs the cached cast object back into the attribute before the path is applied, so everything you changed actually gets persisted. If you are upgrading from v13.34.0, this arrives as a pure bug fix: no new API, no configuration, no migrations.
The bug: a path update that forgot your changes
The PR description demonstrates the problem in three lines:
$user->settings['theme'] = 'dark';
$user->update(['settings->locale' => 'en']);
// saved: {"theme": "light", "locale": "en"}
You would expect {"theme": "dark", "locale": "en"}. Instead, the theme change vanished — no exception, no warning, just a column that looks like your in-memory edit never happened. The same loss occurred through fill(), forceFill(), and direct arrow-key assignment like $user->{'settings->locale'} = 'en', whenever a cast object had unsaved modifications.
Why it happened
When you access an attribute cast to an object, Eloquent materializes a live value — an ArrayObject, a Collection, a Fluent instance, or whatever your custom cast returns — and caches it, so repeated access hands you back the same object. Your mutations live on that cached object, while the raw attribute string still holds the last persisted JSON. That is normally fine: save() knows how to pick up cast-object changes.
A JSON path write, however, never touches the object. fillJsonAttribute() splits the key on ->, decodes the raw attribute JSON, sets the requested path, and re-encodes the result. Because that decode starts from the stale raw string — which never saw your in-memory mutation — the re-encoded value effectively reverted everything you had changed on the cast object. The write was an overwrite, not a merge.
What the fix changes
The diff adds a single call at the top of fillJsonAttribute():
[$key, $path] = explode('->', $key, 2);
$this->mergeAttributeFromCachedCasts($key);
mergeAttributeFromCachedCasts() already existed — it is part of the machinery Eloquent uses to keep cached cast objects in sync with the underlying attributes, including how cast-object mutations are picked up when computing a model's changes. The fix simply invokes it at the right moment: before getArrayAttributeWithValue() decodes anything. The raw attribute now reflects your latest edits first, so the path write builds on the full current state rather than the last persisted JSON.
A regression test, testFillingJSONAttributesKeepsUnsavedChangesOnCastObjects, pins the behavior for all three built-in casts. In practical terms:
$model->settings['theme'] = 'dark';
$model->forceFill(['settings->locale' => 'en']);
$model->settings->getArrayCopy();
// ['theme' => 'dark', 'locale' => 'en']
Both changes survive, in memory and once saved to the database.
What this means for your upgrade
For most applications the fix is invisible in the best possible way: code that was silently losing data now behaves as intended. A few details worth knowing:
-
Read-back consistency. After a path update, reading the attribute returns the merged object, including changes you made before the write.
getChanges()for the column will now contain those mutations too — which is correct, since you did change them. -
The one scenario to check. If any code relied on the old behavior — using a path update as an implicit "reset the column to its persisted values" — that no longer happens. Make the intent explicit instead:
// Deliberately discard in-memory cast changes before a path write: $user->refresh()->update(['settings->locale' => 'en']); -
Scope. Only instances that had the cast object materialized and mutated before the path write are affected. Passing arrow-key paths to
create()is unchanged — a fresh model has no cached casts yet, so the merge is a no-op. -
No extra queries. The
settingscolumn is still written in a singleUPDATE; it just now contains the correct merged JSON.
Takeaways
- JSON path updates (
settings->localekeys) no longer discard unsaved changes made toAsArrayObject,AsCollection,AsFluent, or custom object-cast attributes. - The fix is a one-line call to
mergeAttributeFromCachedCasts()insidefillJsonAttribute(), so the path write merges with in-memory state instead of overwriting it. - Upgrading from v13.34.0 requires no code changes — affected flows simply stop losing data.
- If you depended on the old discard behavior, use
refresh()or perform the path update before mutating the cast object.