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
];
}
}
Source: Laravel — Attribute Casting
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."),
};
}
}
Source: Laravel — Custom Casts and Castables
<?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.
Source: Laravel — Enum Casting, Encrypted Casting and Date Casting
-
The enum cast stores the backed value (
"returned") and returns the case; an unknown value in the column throwsValueErroron read, so add enum cases before data that uses them. -
Moneyis Castable: the model names the value object class, andcastUsing()points to the cast. The four-day overdue fee of 100 cents plus 150 became2.50 EUR, stored as250. The cast also refuses a string such as'1.50', a common source of rounding bugs with money in floats. -
encryptedstored 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. RotatingAPP_KEYrequiresAPP_PREVIOUS_KEYS, or the values become unreadable. -
datetime:Y-m-ddid not apply to thejson_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)"}
Source: Laravel — Accessors and Mutators
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 thecasts()method in new code (Laravel 11);$dateswas removed in Laravel 10. -
getTitleAttribute()/setTitleAttribute()accessor and mutator methods (Stauffer ch. 5) becameAttribute::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
floatordecimalcasts with arithmetic in PHP floats is better as integer cents behind a value object cast. -
JSON columns cast to
arrayand modified in place never worked; useAsArrayObject,AsCollectionor reassign.