Skip to content

Custom Eloquent Casts: Value Objects, Enums, and Encrypted Composites

Go beyond primitive storage with Eloquent's CastsAttributes contract. Build reusable value-object casts, handle composite columns, and encrypt sensitive fields without a single accessor.

Published · Updated · 3 min read

Custom Eloquent Casts: Value Objects, Enums, and Encrypted Composites

Why Custom Casts Beat Accessors and Mutators

Getters and setters scattered across a model are hard to test in isolation and impossible to reuse across models. Laravel's CastsAttributes contract gives you a first-class way to encapsulate that logic into a dedicated class, keep your models thin, and make the transformation testable on its own.

This article covers three practical cast patterns: a value-object cast, a composite-column cast, and an encrypted-JSON cast.


1. Value-Object Cast

Suppose you have a Money value object that wraps an integer amount and a currency string.

// app/Values/Money.php
final readonly class Money
{
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}

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

The cast serialises to a single JSON column:

// app/Casts/MoneyCast.php
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        $data = json_decode($value, true);
        return new Money($data['amount'], $data['currency']);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        if (! $value instanceof Money) {
            throw new \InvalidArgumentException('Expected Money instance.');
        }
        return json_encode(['amount' => $value->amount, 'currency' => $value->currency]);
    }
}

Usage on the model:

protected $casts = [
    'price' => MoneyCast::class,
];

2. Composite-Column Cast

Sometimes a logical concept spans multiple physical columns — for example, a DateRange stored as starts_at and ends_at. Implement CastsAttributes and return multiple keys from set():

class DateRangeCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): DateRange
    {
        return new DateRange(
            Carbon::parse($attributes['starts_at']),
            Carbon::parse($attributes['ends_at']),
        );
    }

    /** @return array<string, mixed> */
    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return [
            'starts_at' => $value->start->toDateTimeString(),
            'ends_at'   => $value->end->toDateTimeString(),
        ];
    }
}

When set() returns an array, Eloquent merges those keys directly into the attributes bag — no extra columns needed in $casts.

Gotcha: the $key passed to get() is whatever key you registered in $casts. For composite casts, that key is virtual; the real columns live in $attributes. Read from $attributes, not $value.


3. Encrypted JSON Cast with Constructor Arguments

Casts accept constructor arguments via the colon syntax. Here's an encrypted cast that accepts a cipher name:

class EncryptedJson implements CastsAttributes
{
    public function __construct(private string $cipher = 'AES-256-CBC') {}

    public function get(Model $model, string $key, mixed $value, array $attributes): array
    {
        return json_decode(decrypt($value), true);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        return encrypt(json_encode($value));
    }
}

Register it with an argument:

protected $casts = [
    'metadata' => EncryptedJson::class . ':AES-128-CBC',
];

Laravel resolves the constructor automatically, passing the string after the colon as the first argument.


Testing Casts in Isolation

Because a cast is a plain PHP class, you can unit-test it without a database:

it('round-trips a Money value object', function () {
    $cast  = new MoneyCast();
    $model = new class extends \Illuminate\Database\Eloquent\Model {};

    $json = $cast->set($model, 'price', new Money(1999, 'USD'), []);
    $back = $cast->get($model, 'price', $json, []);

    expect($back->amount)->toBe(1999)
        ->and($back->currency)->toBe('USD');
});

No factories, no migrations, no HTTP overhead.


Takeaways

  • CastsAttributes replaces accessor/mutator pairs with a reusable, testable class.
  • Return an array from set() to hydrate multiple physical columns from one logical cast.
  • Pass constructor arguments with the ClassName:arg colon syntax.
  • Casts are plain PHP — unit-test them without touching the database.
  • Combine with readonly value objects for immutable domain primitives that are impossible to corrupt.

Frequently asked questions

Can a custom cast hydrate columns that are not the key registered in $casts?

Yes. When set() returns an associative array, Eloquent merges every key in that array into the model's attributes, regardless of which key was registered in $casts. This is how composite casts spanning multiple columns work.

How do I pass runtime arguments to a custom cast?

Use the colon syntax in $casts: 'column' => MyCast::class . ':arg1,arg2'. Laravel splits on the colon and passes the comma-separated values as positional constructor arguments to the cast class.

Should I implement CastsInboundAttributes instead of CastsAttributes?

Use CastsInboundAttributes when you only need to transform data on the way into the database (e.g., hashing a password) and want the raw stored value returned on get. CastsAttributes is the right choice when both directions need transformation.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project