0

Casts — $casts array, custom cast classes

Intermediate5 min read·lv-12-015
interview

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 typeStored asRead asExample
boolean0 / 1bool'is_active' => 'boolean'
arrayJSON stringarray'settings' => 'array'
collectionJSON stringCollection'tags' => 'collection'
datetimeDATETIME stringCarbon'published_at' => 'datetime'
decimal:2DECIMAL colstring'price' => 'decimal:2'
encryptedencrypted blobplaintext'secret' => 'encrypted'
Backed Enumenum valueEnum instance'status' => Status::class
Custom classvariescustom object'money' => MoneyCast::class

Code Example

php
<?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 = 1

Interview 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.