DEV Community

Cover image for Custom Eloquent Casts: The Bridge Between Value Objects and Your Columns
Gabriel Anhaia
Gabriel Anhaia

Posted on

Custom Eloquent Casts: The Bridge Between Value Objects and Your Columns


You open a controller and read $order->total. It returns an int. Is that cents? Dollars? Is the currency stored somewhere else, or did someone assume everything is USD? You check the migration. total is an unsigned integer. You check three call sites. Two divide by 100, one doesn't. There's your rounding bug.

The column is a number. The concept is money. Every place in your code that treats the number as money re-implements the same rules: format it, add tax, compare it, refuse to add EUR to USD. Those rules belong in one type, not scattered across controllers and Blade views.

Custom Eloquent casts are the bridge. The database stays boring (integers and strings). The application works with real value objects. The translation between the two lives in exactly one class.

The value object first

A value object has no ID and no lifecycle. Two Money instances with the same amount and currency are the same money. Make it immutable and give it the rules that used to live in your controllers.

<?php

namespace App\Domain\Shared;

final readonly class Money
{
    public function __construct(
        public int $amountMinor,
        public string $currency,
    ) {
        if ($amountMinor < 0) {
            throw new \InvalidArgumentException(
                'Money cannot be negative',
            );
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \InvalidArgumentException(
                'Cannot add different currencies',
            );
        }

        return new self(
            $this->amountMinor + $other->amountMinor,
            $this->currency,
        );
    }

    public function format(): string
    {
        return number_format($this->amountMinor / 100, 2)
            . ' ' . $this->currency;
    }
}
Enter fullscreen mode Exit fullscreen mode

The readonly class means once built, it never mutates. That property matters later when Eloquent caches the object.

The cast class

A custom cast implements CastsAttributes. Two methods: get() turns raw column values into your object when you read the model, set() turns your object back into column values when you save.

Money needs two columns. That's fine. set() can return an array keyed by column name.

<?php

namespace App\Casts;

use App\Domain\Shared\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

/**
 * @implements CastsAttributes<Money, Money>
 */
final class MoneyCast implements CastsAttributes
{
    public function __construct(
        private readonly string $currencyColumn = 'currency',
    ) {}

    public function get(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): ?Money {
        if ($value === null) {
            return null;
        }

        return new Money(
            (int) $value,
            $attributes[$this->currencyColumn] ?? 'USD',
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The set() method mirrors it, turning a Money back into the two raw columns:

final class MoneyCast implements CastsAttributes
{
    // __construct() and get() as above

    public function set(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): array {
        if ($value === null) {
            return [$key => null];
        }

        if (! $value instanceof Money) {
            throw new \InvalidArgumentException(
                'Expected a Money instance',
            );
        }

        return [
            $key => $value->amountMinor,
            $this->currencyColumn => $value->currency,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things people trip on here. First, get() reads sibling columns from the $attributes array, not from the model, so the currency is available even before the model is fully hydrated. Second, set() returns both columns. If you return only $key, the currency column never updates and you get a Money(500, 'USD') that silently should have been EUR.

Wire it up in the model. On Laravel 11 and 12, the casts() method is the way:

protected function casts(): array
{
    return [
        'total' => MoneyCast::class . ':currency',
    ];
}
Enter fullscreen mode Exit fullscreen mode

The :currency after the colon is passed to the cast constructor. Now $order->total is a Money, and $order->total = $order->total->add($shipping) saves two columns.

Keep the domain type out of the migration

This is the part that pays off. The migration knows nothing about Money:

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('total');   // minor units
    $table->string('currency', 3);
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

No JSON column pretending to be a type. No serialized blob you can't query. SELECT SUM(total) FROM orders WHERE currency = 'USD' still works because the database sees plain integers. The Money type exists only in PHP, where the rules live. The column is storage; the object is behavior. The cast is the only code that knows both.

Making the object castable itself

Repeating MoneyCast::class . ':currency' in every model is noise. The Castable interface moves the wiring onto the value object, so the model just names the class it wants.

<?php

namespace App\Domain\Shared;

use App\Casts\EmailCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

final readonly class Email implements Castable
{
    public function __construct(public string $value)
    {
        if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException(
                "Invalid email: {$value}",
            );
        }
    }

    public static function castUsing(array $arguments): CastsAttributes
    {
        return new EmailCast();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the model reads cleanly:

protected function casts(): array
{
    return [
        'email' => Email::class,
    ];
}
Enter fullscreen mode Exit fullscreen mode

The EmailCast is a single-column cast. get() builds an Email, set() returns the string. Validation lives in the constructor, so an invalid address can never reach the column. The database column is a plain string. The type guarantees it holds a real email.

Serialization edge cases

This is where casts bite back, so walk into them on purpose.

Null. Both get() and set() receive null for nullable columns. Handle it explicitly. A cast that assumes a value throws on every model with an empty field, and you'll only find out in production when someone saves a draft.

toArray() and JSON. When you call $order->toArray() or return the model from an API controller, Eloquent asks the cast how to serialize. By default it calls get() and hands the object to json_encode. A readonly object with public properties serializes to {"amountMinor":500,"currency":"USD"}, which leaks your internal shape. Control it with SerializesCastableAttributes:

use Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes;

final class MoneyCast implements
    CastsAttributes,
    SerializesCastableAttributes
{
    // get() and set() as before

    public function serialize(
        Model $model,
        string $key,
        mixed $value,
        array $attributes,
    ): string {
        return $value->format();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the JSON says "5.00 USD" and your API contract stops depending on private field names.

Dirty checking. Eloquent decides a model is dirty by comparing the raw attributes set() produced against the originals, not by comparing objects. Assign a Money equal to the stored one and the model stays clean, which is what you want. But if your set() is non-deterministic (say it formats a float and rounding drifts), Eloquent thinks the model changed on every save and issues pointless UPDATEs. Keep set() a pure mapping.

Object caching. Eloquent caches the resolved cast object on the model. Read $order->total twice and you get the same instance. If Money were mutable and you did $order->total->amountMinor = 999, the cached object would drift from the columns and never save. This is the real reason to make value objects readonly. Immutability isn't a style preference here; it closes a stale-cache bug. If you genuinely need a mutable cast, opt out by declaring public bool $withoutObjectCaching = true; on the cast class and accept the cost.

Comparing across currencies. The cast reconstructs Money from two columns. If a legacy row has total = 500 but currency = NULL, your get() default of 'USD' quietly invents a currency. Decide that on purpose. Throwing on a missing currency during a backfill is annoying but honest; defaulting silently ships wrong money.

Where this leaves you

The payoff is a codebase where money is Money and email is Email from the controller down. No more guessing whether an int is cents. Validation runs once, in a constructor, instead of in every form request. The rules for adding, formatting, and comparing live in the type, and the cast is the seam that keeps the database ignorant of all of it.

The cast is doing quiet architectural work: it's the adapter between a domain type and a storage detail. The Money object doesn't know it lives in two integer-and-string columns, and the migration doesn't know Money exists. That separation, where the domain type stays clean and the persistence mapping sits at the edge in one swappable class, is the whole idea behind clean and hexagonal architecture. Decoupled PHP is about building the rest of your application that way, so the framework stays a detail instead of the shape of your code.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)