skip to content

With Eloquent, why does $invoice->settings['theme'] = 'dark' fail with an array cast, and how do AsArrayObject and AsCollection fix it?

level: middleimportance: should knowfreq 38%

answer

  1. array cast returns a copy
  2. indirect modification notice
  3. cached cast object merged back
  4. AsCollection::using for custom classes
  5. nullable() stores SQL NULL

basics

~20 s

An array cast decodes JSON into a plain array on each read, so writing an offset modifies a temporary copy and PHP raises an 'indirect modification' notice. AsArrayObject and AsCollection return a cached object whose edits Eloquent re-encodes on save.

solid answer

~40 s

With `'settings' => 'array'`, reading `$invoice->settings` goes through the model's magic getter and returns a freshly decoded array. `$invoice->settings['theme'] = 'dark'` therefore writes into a temporary copy; PHP raises the notice "Indirect modification of overloaded property ... has no effect", which Laravel's error handler turns into an `ErrorException`. The fix with a plain array is to read, change and reassign the whole value. `AsArrayObject::class` instead returns an `ArrayObject`, and `AsCollection::class` a `Collection`; both are custom casts whose returned object is cached on the model, and when the model saves it merges the cached object back and re-encodes it, so offset edits persist. `AsCollection::using(SettingsCollection::class)` picks a collection subclass, and `AsCollection::of(Line::class)` maps each item into a class. Since Laravel 13.33, `AsCollection::nullable()` and `AsArrayObject::nullable()` store SQL `NULL` instead of JSON `null`.

code

php · 18 lines
php
<?php

use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Casts\AsCollection;

protected function casts(): array
{
    return [
        'settings' => AsArrayObject::class,
        'line_items' => AsCollection::of(InvoiceLine::class),
        'metadata' => AsCollection::nullable(),
    ];
}

// elsewhere
$invoice->settings['theme'] = 'dark';     // edits the cached ArrayObject
$invoice->settings->show_tax = true;      // ARRAY_AS_PROPS also allows this
$invoice->save();                         // re-encodes settings as JSON

go deeper

for a junior

Recall that an array cast returns a copy, so assign the whole array back, or use AsArrayObject for in-place edits.

for a middle

Explain the indirect-modification notice, the cast object cache that merges edits back on save, and the AsCollection options.

for a senior

Weigh whole-document writes and lost updates on busy JSON columns, and decide when a targeted JSON path update is safer.

for a principal

Decide which data stays in JSON settings columns and which earns real columns, for querying, constraints and concurrent edits.

## The setting A subscription invoice keeps per-customer display options in a JSON column, `settings`: theme, language, whether to show tax lines. The natural code is: ```php $invoice->settings['theme'] = 'dark'; $invoice->save(); ``` With the plain `array` cast, that fails. ## Why the array cast breaks offset writes `settings` is not a real property on the model. Reading it goes through PHP's `__get`, which Eloquent implements by decoding the stored JSON into a **new PHP array** every time. Writing an offset on the result is an **indirect modification**: PHP would be changing a temporary value that is thrown away. The engine raises an `E_NOTICE` ("Indirect modification of overloaded property ... has no effect"). Laravel's error handler converts reported notices and warnings into an `ErrorException`, so in a Laravel app it is an exception; either way the write never reaches the model. The array-cast fix is explicit reassignment: ```php $settings = $invoice->settings; $settings['theme'] = 'dark'; $invoice->settings = $settings; ``` ## How AsArrayObject and AsCollection fix it Both are **custom cast classes** shipped with Eloquent: | Cast | Read value | Offset write persists? | |---|---|---| | `'array'` / `'json'` | plain PHP array | no — reassign the whole array | | `AsArrayObject::class` | `ArrayObject` (props or offsets) | yes | | `AsCollection::class` | `Illuminate\Support\Collection` | yes, via `put()` or offsets | | `'collection'` | `Collection`, rebuilt each read | no — reassign | The mechanism is Eloquent's **cast object cache**. For a class cast, the first read stores the returned object in the model's cast cache and later reads return the **same object**. Before the model reads its raw attributes — for `save()`, `isDirty()` or `toArray()` — it merges cached cast objects back through the cast's `set()`, which JSON-encodes them. So `$invoice->settings['theme'] = 'dark'` edits the cached `ArrayObject`, and `save()` writes the new JSON. ## Options on AsCollection - `AsCollection::using(SettingsCollection::class)` returns a subclass of `Collection`, so domain methods live on it. - `AsCollection::of(InvoiceLine::class)` maps each decoded item into that class. - `AsCollection::nullable()` and `AsArrayObject::nullable()` store SQL `NULL` when you assign `null`, instead of the JSON literal `null` (added in Laravel 13.33). Because these are static method calls, they must live in the `casts()` method, not in a `$casts` property default. ## Choosing a JSON cast - **Read-only or replaced wholesale** (for example, a payload stored for audit): `array` is simplest and allocates nothing extra. - **Keys edited in place by application code**, such as settings toggles: `AsArrayObject`, which also allows property syntax. - **Lists you filter, map or sum**, such as invoice line items: `AsCollection`, optionally `using()` a domain collection or mapping items `of()` a class. - **Sensitive structured data**: the encrypted variants of the same two casts. - **Values you query often**: consider real columns; JSON casts make PHP convenient but do nothing for SQL filtering or constraints. ## Things to keep in mind 1. **Whole-document writes.** Every save rewrites the entire JSON value. Two requests editing different keys of the same invoice's settings can overwrite each other. 2. **Updating one path in SQL.** For a single key without loading the model, `$invoice->update(['settings->theme' => 'dark'])` targets the JSON path; that is a query-level update, not a cast feature. 3. **Encrypted variants.** `AsEncryptedArrayObject` and `AsEncryptedCollection` behave the same but store ciphertext. 4. **Null reads.** With no stored value, both casts return `null`, so guard `$invoice->settings?->theme` when the column is nullable.

  • Why does the plain 'collection' cast not solve the problem the way AsCollection does?
    The `collection` cast is a primitive cast: it decodes the JSON into a new `Collection` on every read and is not held in the class-cast cache. Changes to that object are never merged back, so you must reassign it. `AsCollection` is a class cast whose object is cached and re-encoded on save.
  • Can two requests editing different settings keys lose each other's changes?
    Yes. The cast serializes and writes the whole JSON document on save, so the later save overwrites the earlier one's keys. For independent keys, a targeted path update such as `update(['settings->theme' => 'dark'])` or row locking avoids the lost update.

saying these in an interview costs you the question

  • Saying the array cast writes offset changes back automatically
  • Believing AsArrayObject stores the column as serialized PHP
  • Declaring AsCollection::using(...) inside a $casts property default
  • Thinking the 'collection' string cast behaves like AsCollection
  • Assuming an offset edit only changes the one JSON key in SQL