Eloquent Casts, Accessors and Serialization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The examples use Bookshelf’s Book and Loan models on PostgreSQL 17.11, run from a script that boots the application. Casts convert a column’s raw value into a PHP type when it is read and back when it is written; accessors and mutators do the same with custom code; serialization decides what toArray() and toJson(), and therefore API responses and Inertia props, contain. The models themselves are on Eloquent Models.

The casts() method

Casts are declared in a casts() method (the $casts property still works). The two models after this page:

<?php
// app/Models/Book.php (excerpt)
namespace App\Models;

use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    protected $hidden = ['cover_path'];                        // never in toArray()/JSON

    protected function casts(): array
    {
        return [
            'published_year' => 'integer',
            'meta' => AsArrayObject::class,                    // JSON column, modifiable in place
        ];
    }

    protected function displayTitle(): Attribute                // accessor: $book->display_title
    {
        return Attribute::make(
            get: fn (mixed $value, array $attributes) => $attributes['title'].($attributes['published_year'] ? " ({$attributes['published_year']})" : ''),
        );
    }

    protected function isbn(): Attribute                        // mutator: normalise on write
    {
        return Attribute::make(set: fn (string $value) => str_replace(' ', '-', trim($value)));
    }
}
<?php
// app/Models/Loan.php (excerpt)
namespace App\Models;

use App\Enums\LoanStatus;
use App\ValueObjects\Money;
use Illuminate\Database\Eloquent\Model;

class Loan extends Model
{
    protected function casts(): array
    {
        return [
            'status' => LoanStatus::class,                     // enum cast
            'due_at' => 'datetime',
            'returned_at' => 'datetime:Y-m-d',                 // format used when serialized
            'late_fee_cents' => Money::class,                  // Castable value object
            'note' => 'encrypted',                             // encrypted at rest with APP_KEY
        ];
    }
}

The built-in types are array, boolean, collection, date, datetime, immutable_date, immutable_datetime, decimal:<precision>, double, encrypted (and encrypted:array, encrypted:collection, encrypted:object), float, hashed, integer, object, real, string and timestamp, plus the cast classes AsArrayObject, AsCollection (AsCollection::of(Option::class) for typed items), AsEnumCollection, AsEnumArrayObject, AsStringable, AsUri, AsFluent, AsBinary and AsVector for pgvector columns.

JSON columns

The meta JSON column of books shows the difference between the array cast and AsArrayObject:

<?php
use App\Models\Book;
use Illuminate\Support\Facades\DB;

$kindred = Book::where('title', 'Kindred')->firstOrFail();

// 1. JSON column with AsArrayObject: modify in place
$kindred->meta = ['formats' => ['paperback'], 'awards' => []];
$kindred->meta['formats'][] = 'ebook';
$kindred->meta['awards']['locus'] = false;
$kindred->save();
echo 'meta: ', DB::table('books')->where('id', $kindred->id)->value('meta'), PHP_EOL;

// 2. the same with a plain 'array' cast
$plain = Book::where('title', 'Kindred')->withCasts(['meta' => 'array'])->first();
try {
    $plain->meta['formats'][] = 'audiobook';
} catch (ErrorException $e) {
    echo 'array cast: ', $e->getMessage(), PHP_EOL;
}
meta: {"formats":["paperback","ebook"],"awards":{"locus":false}}
array cast: Indirect modification of overloaded property App\Models\Book::$meta has no effect

With array, $book->meta returns a copy of the decoded array, so $book->meta['formats'][] = ... changes the copy and PHP warns that the modification has no effect (Laravel’s error handler turns the notice into an exception). The fix with array is to read, change and assign the whole array back; AsArrayObject and AsCollection return objects that write back on save. withCasts() changes casts for one query, here to show the array behaviour on the same row. JSON columns are queried with where('meta->awards->locus', false) and updated in place with update(['meta->awards->locus' => true]) (Database and Query Builder).

Enum, value object, encrypted and date casts

<?php
// app/ValueObjects/Money.php
namespace App\ValueObjects;

use App\Casts\MoneyCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
use JsonSerializable;
use Stringable;

/** An immutable amount in cents; Castable, so models can cast to it by class name. */
final readonly class Money implements Castable, JsonSerializable, Stringable
{
    public function __construct(public int $cents, public string $currency = 'EUR') {}

    public static function castUsing(array $arguments): string
    {
        return MoneyCast::class;
    }

    public function plus(Money $other): self
    {
        return new self($this->cents + $other->cents, $this->currency);
    }

    public function __toString(): string
    {
        return number_format($this->cents / 100, 2).' '.$this->currency;
    }

    public function jsonSerialize(): array
    {
        return ['cents' => $this->cents, 'currency' => $this->currency, 'formatted' => (string) $this];
    }
}
<?php
// app/Casts/MoneyCast.php
namespace App\Casts;

use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;

/** Stores Money as integer cents in one column. */
class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
    {
        return $value === null ? null : new Money((int) $value);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): ?int
    {
        return match (true) {
            $value === null => null,
            $value instanceof Money => $value->cents,
            is_int($value) => $value,
            default => throw new InvalidArgumentException("[$key] must be Money or integer cents."),
        };
    }
}
<?php
use App\Enums\LoanStatus;
use App\Models\Loan;
use App\ValueObjects\Money;
use Illuminate\Support\Facades\DB;

$loan = Loan::first();
$loan->status = LoanStatus::Returned;
$loan->late_fee_cents = $loan->late_fee_cents->plus(new Money(150));
$loan->note = 'Returned with a torn dust jacket';
$loan->returned_at = now();
$loan->save();
$raw = DB::table('loans')->where('id', $loan->id)->first();
echo 'enum: ', get_debug_type($loan->status), ' -> stored "', $raw->status, '"', PHP_EOL;
echo 'money: ', $loan->late_fee_cents, ' -> stored ', $raw->late_fee_cents, PHP_EOL;
echo 'encrypted: "', $loan->note, '" -> stored ', substr($raw->note, 0, 24), '… (', strlen($raw->note), ' chars)', PHP_EOL;
echo 'json: ', json_encode($loan->only('status', 'late_fee_cents', 'due_at', 'returned_at')), PHP_EOL;
try { $loan->late_fee_cents = '1.50'; } catch (InvalidArgumentException $e) { echo 'cast guard: ', $e->getMessage(), PHP_EOL; }
enum: App\Enums\LoanStatus -> stored "returned"
money: 2.50 EUR -> stored 250
encrypted: "Returned with a torn dust jacket" -> stored eyJpdiI6InQzd3kzWEZGQWE4… (256 chars)
json: {"status":"returned","late_fee_cents":{"cents":250,"currency":"EUR","formatted":"2.50 EUR"},"due_at":"2026-10-01T20:56:13.000000Z","returned_at":"2026-10-05T21:01:12.853671Z"}
cast guard: [late_fee_cents] must be Money or integer cents.
  • The enum cast stores the backed value ("returned") and returns the case; an unknown value in the column throws ValueError on read, so add enum cases before data that uses them.

  • Money is Castable: the model names the value object class, and castUsing() points to the cast. The four-day overdue fee of 100 cents plus 150 became 2.50 EUR, stored as 250. The cast also refuses a string such as '1.50', a common source of rounding bugs with money in floats.

  • encrypted stored a 32-character note as a 256-character payload (AES-256-CBC with a MAC, base64-encoded), so the column needs room to grow and can no longer be searched or indexed. Rotating APP_KEY requires APP_PREVIOUS_KEYS, or the values become unreadable.

  • datetime:Y-m-d did not apply to the json_encode($loan->only(...)) output above: only() returns the Carbon objects, and Carbon serializes to ISO 8601. The format is applied by the model’s own serialization:

only():    {"returned_at":"2026-10-05T21:01:12.000000Z"}
toArray(): {"due_at":"2026-10-01T20:56:13.000000Z","returned_at":"2026-10-05"}

Dates serialize as UTC ISO 8601 with microseconds by default, which JavaScript’s Date parses; override serializeDate() on a model to change it everywhere.

Accessors and mutators

An accessor or mutator is a protected method named after the attribute in camel case, returning Attribute::make(get: ..., set: ...):

<?php
use App\Models\Book;

$book = new Book(['isbn' => ' 978 0 446 60377 6 ']);
echo 'mutator: "', $book->isbn, '"', PHP_EOL;
echo 'accessor: ', $kindred->display_title, PHP_EOL;
echo 'toArray keys: ', implode(',', array_keys($kindred->toArray())), PHP_EOL;
echo 'with append: ', json_encode($kindred->append('display_title')->only('id', 'display_title')), PHP_EOL;
mutator: "978-0-446-60377-6"
accessor: Kindred (1979)
toArray keys: id,author_id,title,isbn,published_year,created_at,updated_at,meta
with append: {"id":1,"display_title":"Kindred (1979)"}

The mutator normalised the ISBN at assignment, before validation or saving, so every write path gets the same format. The display_title accessor is computed (there is no such column) and is not part of toArray() unless appended, per call with append() or always with $appends (#[Appends([...])] in Laravel 13). get receives all raw attributes as its second argument, which is how an accessor combines columns; Attribute::make(...)->shouldCache() memoises an expensive value object for the life of the model instance.

Serialization

toArray() and toJson() (and returning a model from a route) include all attributes except the hidden ones, the loaded relationships, and appended accessors. cover_path was missing from the book’s keys above because of $hidden, and the User model of a new application hides the password hash and remember token:

user json: {"id":1,"name":"Ada Reader","email":"ada@example.com","email_verified_at":"2026-10-05T20:56:12.000000Z","created_at":"2026-10-05T20:56:13.000000Z","updated_at":"2026-10-05T20:56:13.000000Z"}

$visible (or #[Visible]) lists the only attributes to include, #[Hidden] replaces the $hidden property in Laravel 13, and makeVisible()/makeHidden() change it for one instance. Hiding is a deny list: a column added later (two_factor_secret, stripe_id) is published until someone hides it, which is why APIs usually serialize through API Resources that list fields explicitly (Building APIs and API Resources).

Where older books and tutorials are outdated

  • protected $casts = [...] and $dates (Stauffer ch. 5) are the casts() method in new code (Laravel 11); $dates was removed in Laravel 10.

  • getTitleAttribute() / setTitleAttribute() accessor and mutator methods (Stauffer ch. 5) became Attribute::make() methods in Laravel 9; the old style still works but cannot be combined with caching.

  • Status strings compared by hand are enum casts of PHP 8.1 enums (documented since Laravel 9).

  • Money in float or decimal casts with arithmetic in PHP floats is better as integer cents behind a value object cast.

  • JSON columns cast to array and modified in place never worked; use AsArrayObject, AsCollection or reassign.