Skip to content

Update our override of Laravel's attributesToArray()#223

Merged
LukeTowers merged 6 commits into
developfrom
attributes-to-array
Jul 22, 2026
Merged

Update our override of Laravel's attributesToArray()#223
LukeTowers merged 6 commits into
developfrom
attributes-to-array

Conversation

@mjauvin

@mjauvin mjauvin commented Dec 15, 2025

Copy link
Copy Markdown
Member

Why

Storm overrides attributesToArray() so it can fire the model.beforeGetAttribute / model.getAttribute events and decode $jsonable columns. To do that, the override hand-copied Laravel's date / mutator / cast loops inline.

That copy was taken from an old version of Laravel and never kept in sync. Laravel has since moved the cast handling into addDateAttributesToArray() / addMutatedAttributesToArray() / addCastAttributesToArray() and added a whole post-cast serialization step that our copy simply doesn't have. The result is that several documented Laravel cast behaviours are silently broken — or outright fatal — on Winter models.

This PR keeps the Winter-specific bits (both events, $jsonable, $appends, and the existing ordering) and delegates the rest to the base model, deleting ~46 lines of stale duplication.

What this actually fixes

Verified against a probe model exercising every cast type, before and after:

1. Pure (non-backed) enum casts crash toJson()

enum Status { case Draft; case Live; }
protected $casts = ['status' => Status::class];

toArray() currently leaves the enum instance in the array, so encoding blows up:

JsonException: Non-backed enums have no default serialization

Laravel's getStorableEnumValue() handles this (->value for backed, ->name for pure). After: "status": "Live".

2. SerializesCastableAttributes::serialize() is never called

A custom cast implementing this interface is a documented Laravel contract, and we ignore it — the raw value object leaks into toArray() and JSON output becomes whatever the object's public properties happen to be ({"cents":1250} instead of the intended "SERIALIZED:1250").

3. date:<format> / datetime:<format> formats are silently ignored

protected $casts = ['published_at' => 'datetime:Y/m/d'];

Currently emits the full ISO-8601 string in toArray()/toJson() — the requested format is dropped without warning. After: 2025/12/31.

4. AsCollection / AsArrayObject / AsEncryptedCollection leak objects

toArray() returns a Collection / ArrayObject nested inside the array, so the method doesn't honour its own contract of returning a plain array. Anything doing array_walk_recursive(), strict comparison, or plain array traversal over the result hits an object. Laravel's Arrayable check flattens these.

5. toArray()['id'] disagrees with $model->id

getAttribute() already applies the implicit primary-key cast via getCasts(), but our loop iterated the raw $this->casts property, which doesn't include it. On any driver/config that returns column values as strings, that means:

$model->id;                  // int(5)
$model->toArray()['id'];     // string("5")   ->  API sends {"id":"5"}

Two different types for the same attribute, which is a reliable source of strict-equality bugs in front-end code consuming the JSON.

6. Maintenance

Every future Laravel cast feature currently requires a manual re-port into our override, and the omissions above are exactly what happens when that doesn't occur. Delegating to the base model means we inherit them going forward.

Compatibility notes

  • JSON output is unchanged except in the broken cases above (pure enums, SerializesCastableAttributes, custom date formats, and id on string-returning drivers).
  • toArray() value types do change where a cast is declared: datetime/date casts now yield the serialized string rather than an Argon instance, AsCollection yields an array rather than a Collection, and enum casts yield the scalar. This matches upstream Laravel. Code doing $model->toArray()['some_datetime_cast']->format(...) will need updating — worth an upgrade note.
  • Ordinary timestamps are unaffected. created_at/updated_at are covered by getDates() and were already serialized to strings before this change.
  • Models declaring no $casts see no change at all.

Known edge case

When a column appears in both getDates() and a custom-format cast, addDateAttributesToArray() serializes it to UTC ISO-8601 first, and addCastAttributesToArray() then re-parses and formats that UTC value — so the custom format renders in UTC rather than the app timezone. This is inherited from Laravel rather than introduced here, but it's why the new test sets $timestamps = false in order to use created_at/updated_at in $casts.

Testing

  • Adds testAddDatesCasts() covering date:<format>, datetime:<format> and bare date casts.
  • Full Storm suite: no new failures (the pre-existing ArrayFileTest / ConfigWriterTest formatting failures on develop are unrelated).
  • Full Winter CMS core suite passes against a patched Storm; core itself declares no $casts outside the model stub, so there is no internal exposure.

Summary by CodeRabbit

  • Refactor

    • Streamlined attribute transformation so date handling, mutations, and casts are processed consistently via centralized routines.
  • Tests

    • Expanded coverage for attribute serialization, including date/cast formatting and validation of resulting attribute output.
    • Added a comprehensive test suite for attributesToArray() behavior, covering JSON decoding rules, correct precedence between mutators and casts, enum/cast serialization, arrayable casts, primary-key casting, null preservation, and appended attributes.

@mjauvin
mjauvin requested a review from LukeTowers December 15, 2025 22:47
@mjauvin mjauvin self-assigned this Dec 15, 2025
@coderabbitai

This comment was marked as resolved.

@mjauvin
mjauvin marked this pull request as draft December 15, 2025 22:47
@mjauvin
mjauvin marked this pull request as ready for review December 16, 2025 05:56
@mjauvin mjauvin added this to the 1.2.10 milestone Dec 16, 2025
@mjauvin mjauvin added the maintenance PRs that fix bugs, are translation changes or make only minor changes label Dec 16, 2025
@mjauvin mjauvin changed the title Emulate Laravel attributesToArray() Update our override of Laravel's attributesToArray() Dec 16, 2025
@LukeTowers LukeTowers modified the milestones: 1.2.10, 1.2.11 Feb 4, 2026
@LukeTowers LukeTowers modified the milestones: 1.2.12, v1.2.13 Mar 13, 2026
@github-actions github-actions Bot closed this Jul 17, 2026
@mjauvin mjauvin reopened this Jul 17, 2026
@github-actions github-actions Bot closed this Jul 21, 2026
@mjauvin mjauvin reopened this Jul 21, 2026
@wintercms wintercms deleted a comment from github-actions Bot Jul 22, 2026
@wintercms wintercms deleted a comment from github-actions Bot Jul 22, 2026
LukeTowers and others added 2 commits July 22, 2026 15:18
Storm overrides attributesToArray() so that it can fire the
model.beforeGetAttribute / model.getAttribute events and decode $jsonable
columns. None of that was covered: stripping both events and the $jsonable
loop out of the method left the suite entirely green, so nothing guarded the
only reason the override exists.

Adds tests for both halves of the contract:

- Winter specific behaviour that must survive future re-syncs with Laravel:
  $jsonable decoding (including invalid JSON, double decoding and mutator
  precedence), both attribute events, and the ordering guarantee that
  model.getAttribute observes fully serialized values.

- The cast handling this branch fixes, each of which fails against the
  previous hand-rolled cast loops: pure and backed enum casts, casts
  implementing SerializesCastableAttributes, Arrayable casts, and the
  implicit primary key cast that made $model->id and toArray()['id']
  disagree on drivers returning columns as strings.

Also pins null cast values, mutator-over-cast precedence and appends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mutator returned a non-JSON value, so json_decode() failed and the value
survived whether or not the $jsonable loop skipped mutated attributes. The
test passed with the in_array($key, $mutatedAttributes) guard removed, making
it a tautology that only restated testMutatorsTakePrecedenceOverCasts.

Return valid JSON from the mutator instead, so that dropping the guard decodes
the string into an array and fails the assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@LukeTowers
LukeTowers merged commit 5c9e47c into develop Jul 22, 2026
13 checks passed
@LukeTowers
LukeTowers deleted the attributes-to-array branch July 22, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintenance PRs that fix bugs, are translation changes or make only minor changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants