Models & data

How Eloquent casts raw database values into objects

The mistake

Most people read $user->status as a typed property on the object: assign to it and the row changes, read it and you get what the column holds.

Neither half happens. Eloquent keeps the row as raw values in an internal $attributes array, and $casts says how to convert them. That conversion runs when you read. Writing does not run the same conversion backwards: it stores what you gave it, converting on the way in only for some casts. Nothing reaches the database until save(), and what counts as a change is worked out then, by comparing $attributes against $original.

The machine

Simulator · Attribute Casting

Model attributes, cast transformations, and original snapshot run on the tested reducer.

Attribute casting simulator. Model matches original state.

Drive it

  • Read a cast property. Click $user->status or $user->settings. The raw strings "draft" and '{"theme":"dark"}' become an enum and an array as you read them.
  • Change a value. Toggle the status or the settings. The raw $attributes array changes and $original stays untouched.
  • Toggle is_admin twice. The raw entry does not come back to where it started: it began as the column string '0' and is now the PHP false. Eloquent reports isDirty() as false anyway, because for a primitive cast it compares the two sides cast, not raw.
  • Watch dirty tracking. isDirty() turns true on a real change, and back to false once $user->save() syncs the two arrays.

The mechanism

Eloquent models store raw database columns inside the internal $attributes array. The $casts property tells Eloquent how to convert those raw values to native PHP types when you read a property. The path is __get, then getAttribute.

Writing is not the mirror image of reading. setAttribute converts on the way in only for some casts: an enum is stored as its backing value, an array or json cast is serialised to a JSON string, a datetime is formatted to a date string. A primitive cast like boolean or integer converts nothing. $user->is_admin = true puts the PHP true straight into $attributes, sitting alongside raw column strings like '0'. The panel above shows quotes for the strings so you can tell the two apart.

When you call $user->save(), Eloquent compares $attributes against $original. Only keys that differ are included in the SQL UPDATE. After the query completes, Eloquent copies $attributes into $original, resetting the dirty state.

That comparison is not raw equality, which matters because of the rule above. originalIsEquivalent first checks whether the two raw values are identical. If they are not, and the key has a primitive cast, it casts both sides and compares the results. So assigning false over a raw '0' genuinely changes the array, and isDirty() still returns false, because both sides cast to false and nothing needs writing.

In your code

class User extends Model
{
    protected $casts = [
        'status' => StatusEnum::class,
        'is_admin' => 'boolean',
        'settings' => 'array',
        'published_at' => 'datetime',
    ];
}

$user = User::find(42);

// Accessing reads raw $attributes['status'] and converts it to StatusEnum
if ($user->status === StatusEnum::Draft) {
    // Mutating converts StatusEnum::Published back to raw string 'published'
    $user->status = StatusEnum::Published;
}

// $user->isDirty('status') is true because $attributes['status'] != $original['status']
$user->save(); // Runs UPDATE, then syncs $original = $attributes

The fine print

  • A custom cast class implementing CastsAttributes controls both directions: get() on the way out and set() on the way in.
  • Changing an array or JSON attribute in place needs care. Use array access or the AsArrayObject cast, or dirty tracking will not see a nested change.
  • An accessor, getFooAttribute() or an Attribute return, wins over the raw column value. getAttribute is not an accessor: it is the resolver that runs them.

Further reading

Spotted a problem, or have a way to make this clearer? Suggest an improvement.