Skip to content
LaravelBlog

laravel/framework: Fix JSON path updates on `AsEncryptedArrayObject` and `AsEncryptedCollection`

LaravelBlogBot
LaravelBlogBot

Contributed by xurshudyan

Part of the framework v13.35.0 release

Pull request #61834 fixes a silent data-corruption bug that affects every app on 13.34.0 using AsEncryptedArrayObject or AsEncryptedCollection. When you updated a single nested key of such an attribute with the -> JSON-path syntax, Eloquent rewrote the whole column behind your back: every other key in the payload was dropped, the surviving value was stored as unencrypted plaintext, and the next read of the attribute threw a DecryptException. The patch teaches Eloquent's nested-attribute internals to decrypt before merging and re-encrypt before saving, matching what those two casts already do for whole-attribute writes.

The bug in practice

// casts: 'secrets' => AsEncryptedArrayObject::class

$user->secrets = ['stripe_key' => 'sk_123', 'api_token' => 'old'];
$user->save();

$user->update(['secrets->api_token' => 'new']);

After that update(), the database column no longer contains an encrypted payload. It contains the raw JSON {"api_token":"new"} — stripe_key is gone, and the value is readable by anyone with database access. The next attempt to read $user->secrets throws a DecryptException, because the cast hands the plaintext JSON string to the encrypter.

The trigger is narrow: only nested-path updates (secrets->api_token) on these two encrypted class casts were affected. Assigning the whole attribute ($user->secrets = [...]) was always safe, and the string-style encrypted:array and encrypted:collection casts were never involved.

Why nested updates broke these casts

A nested update does not write the column directly. Eloquent reads the current attribute through getArrayAttributeByKey(), merges your key with Arr::set(), and stores the merged result through fillJsonAttribute(). Both methods had an encryption branch, but it was gated on isEncryptedCastable() — a check that only recognises the string casts encrypted, encrypted:array, encrypted:collection, encrypted:json and encrypted:object.

AsEncryptedArrayObject and AsEncryptedCollection are class casts, so that check returned false. During a nested update, Laravel therefore tried to JSON-decode the stored ciphertext as if it were ordinary JSON. Decoding an encrypted blob yields nothing usable, so the merge started from an empty array — hence the missing keys. The write path skipped encryption for the same reason, storing the merged fragment as plaintext. Your data was silently mangled rather than raising an error at write time, which is what makes this bug dangerous in production.

What the fix changes

The pull request adds an isEncryptedClassCastable() method to HasAttributes. It returns true when the attribute is a class cast whose definition starts with AsEncryptedArrayObject::class or AsEncryptedCollection::class. Both fillJsonAttribute() and getArrayAttributeByKey() now use a match (true) expression with three branches:

$this->attributes[$key] = match (true) {
    $this->isEncryptedCastable($key) => $this->castAttributeAsEncryptedString($key, $value),
    $this->isEncryptedClassCastable($key) => Crypt::encryptString($value),
    default => $value,
};

So encrypted:* string casts keep their existing path, the encrypted class casts now round-trip through Crypt::encryptString() and Crypt::decryptString() — the same calls the casts themselves make on get and set — and everything else is untouched. Two new integration tests, testAsEncryptedArrayObjectJsonAttributeIsCastable and testAsEncryptedCollectionJsonAttributeIsCastable, lock the behaviour in:

$subject->mergeCasts(['secret_array' => AsEncryptedArrayObject::class]);

$subject->secret_array = ['key1' => 'value1'];
$subject->fill(['secret_array->key2' => 'value2']);
$subject->save();

// both keys survive and the column holds the encrypted string
$this->assertSame(
    ['key1' => 'value1', 'key2' => 'value2'],
    $subject->secret_array->getArrayCopy()
);

What it means for your upgrade

Upgrading is a drop-in patch: no API changes, no new configuration, no migrations. The change is confined to HasAttributes, and the new isEncryptedClassCastable() method is protected. If a subclass overrides fillJsonAttribute() or getArrayAttributeByKey() and copied the old implementations, review that override against the new logic.

The one thing worth acting on is data repair. The fix prevents future corruption, but it cannot heal rows already written through this bug on 13.34.0. If any code path performed a nested update on an AsEncryptedArrayObject or AsEncryptedCollection attribute in production, those records now hold plaintext JSON with missing keys and will throw a DecryptException when read. Look for columns that no longer resemble an encrypted payload, restore the lost keys from a backup or source of truth, and re-save the models so they are encrypted again.

Takeaways

  • Nested JSON-path updates (attribute->key) on AsEncryptedArrayObject and AsEncryptedCollection attributes no longer corrupt the column.
  • The root cause was that fillJsonAttribute() and getArrayAttributeByKey() only handled encryption for the encrypted:* string casts.
  • The fix adds isEncryptedClassCastable() and encrypts/decrypts these casts via Crypt, consistent with the casts' own get/set behaviour.
  • Upgrading requires no code changes, but audit rows written through nested updates before the patch — they may contain plaintext, missing keys, and will throw DecryptException.

Category: Bug fix (9)

Sources

More from this release

Related Articles