Advanced Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms
#laravel #eloquent #value-objects #domain-driven-design #testing

Advanced Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms

4 min read Mohamed Said Mohamed Said

Why Built-in Casts Are Not Enough

Laravel ships with a solid set of primitive casts — integer, boolean, array, encrypted, AsCollection, and friends. They cover 80% of everyday needs. The remaining 20% is where models quietly accumulate logic they should never own: formatting phone numbers, normalising currency, converting units, enforcing invariants.

Custom cast classes move that responsibility to a dedicated type, tested in isolation, reusable across models.


Anatomy of a Custom Cast

A cast class implements CastsAttributes. The contract is simple:

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use App\ValueObjects\Money;

class MoneyCast implements CastsAttributes
{
    public function __construct(
        private readonly string $currency = 'GBP'
    ) {}

    /** @param int $value raw pence stored in DB */
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return Money::fromMinorUnits((int) $value, $this->currency);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): int
    {
        if ($value instanceof Money) {
            return $value->minorUnits();
        }

        return (int) $value;
    }
}

Declare it on the model using the constructor-argument syntax introduced in Laravel 9:

protected function casts(): array
{
    return [
        'price' => MoneyCast::class . ':USD',
        'tax'   => MoneyCast::class,          // defaults to GBP
    ];
}

The model now returns a Money value object from $order->price, and accepts either a Money instance or a raw integer on assignment.


Value Objects as First-Class Citizens

A value object should be immutable and self-validating:

final class Money
{
    private function __construct(
        private readonly int    $minorUnits,
        private readonly string $currency,
    ) {
        if ($minorUnits < 0) {
            throw new \DomainException('Money cannot be negative.');
        }
    }

    public static function fromMinorUnits(int $units, string $currency): self
    {
        return new self($units, strtoupper($currency));
    }

    public function minorUnits(): int  { return $this->minorUnits; }
    public function currency(): string { return $this->currency; }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException('Currency mismatch.');
        }
        return new self($this->minorUnits + $other->minorUnits, $this->currency);
    }

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

The invariant (>= 0) is enforced at construction time — not scattered across service classes.


Inbound-Only Casts

Sometimes you only need to transform data on the way in — hashing a token, normalising a slug, uppercasing a country code. Implement CastsInboundAttributes instead:

use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;

class NormaliseCountryCode implements CastsInboundAttributes
{
    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        return strtoupper(trim((string) $value));
    }
}

The get side is intentionally absent — the raw database value is returned as-is. This is perfect for write-time normalisation without the overhead of a full bidirectional cast.


Casting to Multiple Columns

A single value object can map to multiple database columns by returning an array from set:

public function set(Model $model, string $key, mixed $value, array $attributes): array
{
    return [
        'amount'   => $value->minorUnits(),
        'currency' => $value->currency(),
    ];
}

public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
    return Money::fromMinorUnits(
        (int) $attributes['amount'],
        $attributes['currency'],
    );
}

Declare the virtual key on the model:

'price' => MoneyCast::class,

Eloquent will merge the returned array into the dirty attributes automatically.


Testing the Cast in Isolation

Because the cast is a plain PHP class, you can test it without booting the framework:

it('converts minor units to a Money value object', function () {
    $cast  = new MoneyCast('EUR');
    $model = new class extends Model {};

    $money = $cast->get($model, 'price', 1999, []);

    expect($money)->toBeInstanceOf(Money::class)
        ->and($money->minorUnits())->toBe(1999)
        ->and($money->currency())->toBe('EUR');
});

it('rejects negative minor units', function () {
    expect(fn () => Money::fromMinorUnits(-1, 'EUR'))
        ->toThrow(\DomainException::class);
});

No database, no HTTP — fast, deterministic, and meaningful.


Key Takeaways

  • Use CastsAttributes for bidirectional transforms; use CastsInboundAttributes when you only need write-time normalisation.
  • Pass constructor arguments via the ClassName:arg1,arg2 syntax to make casts reusable across currencies, locales, or units.
  • Return an array from set to map a single virtual attribute to multiple physical columns.
  • Keep value objects immutable and self-validating — the cast is just the bridge, not the domain logic.
  • Test cast classes as plain PHP; no RefreshDatabase needed.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a custom cast class be shared across multiple models?
Yes. A cast class is a plain PHP class with no model-specific coupling. Declare it in the `casts()` method of any model that needs it, optionally passing constructor arguments to vary behaviour per model.
Q02 What happens if the database column is NULL and my cast tries to construct a value object?
The `$value` parameter will be `null`. Guard against it explicitly in `get()` — either return `null` (and type-hint the return as `?Money`) or return a sensible default such as `Money::zero($currency)`.
Q03 Does returning an array from `set()` work with mass assignment and `fill()`?
Yes. Eloquent merges the returned array into the model's attributes before persisting. Ensure the physical column names (`amount`, `currency`) are included in `$fillable` or that `$guarded` is empty, otherwise the merge is silently dropped.

Continue reading

More Articles

View all