Casts — $casts array, custom cast classes
Concept
The $casts array instructs Eloquent how to transform attribute values when reading from and writing to the database. At read time, Illuminate\Database\Eloquent\Concerns\HasAttributes::castAttribute() inspects the cast type, delegates to the appropriate handler, and returns a PHP-typed value. At write time, setAttribute() runs the reverse transformation before placing the value in $this->attributes. This bidirectional contract is what makes casts different from accessors-only.
Built-in primitive casts — integer, float, string, boolean, array, object, collection, datetime, decimal:N, encrypted — cover the vast majority of use cases. The array cast JSON-encodes on write and json_decodes on read. The collection cast goes further, returning an Illuminate\Support\Collection instance. The datetime and date casts produce Carbon\Carbon objects, which is why you can call $user->created_at->diffForHumans() without any manual parsing.
Custom cast classes implement Illuminate\Contracts\Database\Eloquent\CastsAttributes. The interface requires get() and set() methods. You reference the class in $casts as the FQCN, optionally with a colon-separated argument: 'price' => MoneyCast::class . ':USD'. The argument is passed to both get and set as the $attributes array context. Inbound-only casts (implementing CastsInboundAttributes) skip the get method for write-only transformations like hashing.
Enum casts (PHP 8.1+) are first-class: 'status' => OrderStatus::class. Laravel detects the enum's backing type automatically and calls from() on read, ->value on write. This eliminates string magic from model attributes entirely.
| Cast type | Stored as | Read as | Example |
|---|---|---|---|
boolean | 0 / 1 | bool | 'is_active' => 'boolean' |
array | JSON string | array | 'settings' => 'array' |
collection | JSON string | Collection | 'tags' => 'collection' |
datetime | DATETIME string | Carbon | 'published_at' => 'datetime' |
decimal:2 | DECIMAL col | string | 'price' => 'decimal:2' |
encrypted | encrypted blob | plaintext | 'secret' => 'encrypted' |
| Backed Enum | enum value | Enum instance | 'status' => Status::class |
| Custom class | varies | custom object | 'money' => MoneyCast::class |
Code Example
<?php
declare(strict_types=1);
namespace App\Casts;
use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
// Custom cast: stores pence in DB integer, exposes Money value object
class MoneyCast implements CastsAttributes
{
public function __construct(private readonly string $currency = 'GBP') {}
// DB stores: 1999 (integer pence)
// PHP reads: Money{amount: 1999, currency: 'GBP'}
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
return new Money((int) $value, $this->currency);
}
// PHP writes: Money object → integer pence
public function set(Model $model, string $key, mixed $value, array $attributes): int
{
return $value instanceof Money ? $value->amountInPence : (int) $value;
}
}
// --- Model ---
namespace App\Models;
use App\Casts\MoneyCast;
use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $casts = [
'total' => MoneyCast::class . ':GBP', // custom with arg
'status' => OrderStatus::class, // backed enum
'metadata' => 'array', // JSON ↔ PHP array
'confirmed_at' => 'datetime', // string ↔ Carbon
'is_paid' => 'boolean', // 0/1 ↔ bool
'tax_rate' => 'decimal:4', // precision decimal
];
}
// --- Usage ---
$order = Order::find(1);
// Reading: cast runs automatically
$order->total; // Money object — value from DB integer 1999 → Money{1999, 'GBP'}
$order->status; // OrderStatus enum — DB string 'pending' → OrderStatus::Pending
$order->metadata; // PHP array — DB '{"key":"val"}' → ['key' => 'val']
$order->confirmed_at; // Carbon — DB '2025-01-01 12:00:00' → Carbon instance
$order->is_paid; // bool — DB 1 → true
// Writing: reverse cast runs on setAttribute
$order->status = OrderStatus::Shipped; // stores 'shipped' string to DB
$order->metadata = ['note' => 'urgent']; // stores '{"note":"urgent"}' JSON
$order->save();
// SQL: UPDATE orders SET status = 'shipped', metadata = '{"note":"urgent"}' WHERE id = 1Interview Q&A
Q: What is a custom cast class in Eloquent and when should you reach for one instead of an accessor/mutator pair?
A custom cast class implements Illuminate\Contracts\Database\Eloquent\CastsAttributes with get() and set() methods. The advantage over an accessor/mutator pair is reuse across models — you register the cast once in $casts and the same transformation applies everywhere without copying closures. Custom casts are the right tool when you need a symmetrical transformation (read and write), when the cast is domain-specific (Money, Coordinate, Color), or when you need to pass configuration (currency code, precision) via the colon argument syntax. Accessors and mutators are better for model-specific computed values that don't need sharing.
Q: How does Eloquent handle PHP 8.1 backed enums in $casts?
Laravel checks whether the cast value is the FQCN of an enum and whether that enum implements BackedEnum. On get, it calls YourEnum::from($storedValue), converting the raw database scalar (a string or integer) into an enum instance. On set, it accesses ->value on the enum to extract the backing value before storing. If the value is null and the column is nullable, the cast short-circuits and returns null. Unit enums (no backing type) cannot be stored without a custom cast since they have no scalar representation.
Q: The encrypted cast type is listed in $casts — what does it actually do and what are its limitations?
'field' => 'encrypted' tells Eloquent to pass the value through Laravel's Crypt facade (using the APP_KEY-derived encryption key) before writing, and to decrypt it with Crypt::decryptString() on read. The ciphertext stored in the database is an AES-256-CBC encrypted, base64-encoded, HMAC-signed string — meaning it is authenticated encryption, not just obfuscation. Limitations: the stored value is much larger than the plaintext (breaking any VARCHAR length assumptions), the field cannot be queried with a WHERE clause because each encryption of the same value produces a different ciphertext, and rotating the APP_KEY without re-encrypting existing rows breaks all reads.