Eloquent Custom Casts: Encapsulating Value Objects Without the Bloat
#laravel #eloquent #domain-driven-design #value-objects #testing

Eloquent Custom Casts: Encapsulating Value Objects Without the Bloat

1 min read Mohamed Said Mohamed Said

Why Custom Casts Beat Accessors and Mutators

Accessors and mutators (get/set attribute methods) are convenient, but they scatter transformation logic across your model and return plain scalars. A custom cast implements CastsAttributes and gives you a dedicated class that is independently testable, reusable across models, and expressive about what the column actually holds.

The canonical use-case: a money column stored as an integer (cents) that your application always treats as a Money value object.

Defining the Value Object

<?php

namespace App\Domain\Billing\ValueObjects;

final readonly class Money
{
    public function __construct(
        public readonly int $amount,   // cents
        public readonly string $currency,
    ) {}

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

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

The value object is a plain PHP class — no framework dependencies, fully unit-testable.

Implementing the Cast

<?php

namespace App\Domain\Billing\Casts;

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

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

    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money((int) $value, $this->currency);
    }

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

        return (int) $value;
    }
}

The constructor argument makes the cast configurable per-column — a pattern Laravel supports natively.

Registering on the Model

use App\Domain\Billing\Casts\MoneyCast;

class Invoice extends Model
{
    protected function casts(): array
    {
        return [
            'subtotal'  => MoneyCast::class . ':USD',
            'tax'       => MoneyCast::class . ':USD',
            'total'     => MoneyCast::class . ':USD',
        ];
    }
}

Now $invoice->total is always a Money instance — no accidental integer arithmetic leaking into controllers.

Handling JSON Columns with CastsAttributes

For composite value objects backed by a JSON column, serialize to an array in set and reconstruct in get:

public function get(Model $model, string $key, mixed $value, array $attributes): Address
{
    $data = json_decode($value, true);
    return new Address($data['line1'], $data['city'], $data['postcode']);
}

public function set(Model $model, string $key, mixed $value, array $attributes): string
{
    return json_encode([
        'line1'    => $value->line1,
        'city'     => $value->city,
        'postcode' => $value->postcode,
    ]);
}

Inbound-Only Casts

When you only need transformation on the way in (e.g., normalising a phone number before storage), implement CastsInboundAttributes instead. This signals intent clearly and avoids the overhead of a get path.

class E164PhoneCast implements CastsInboundAttributes
{
    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        return preg_replace('/[^\d+]/', '', (string) $value);
    }
}

Testing the Cast in Isolation

Because CastsAttributes is a plain class, you can unit-test it without booting the framework:

it('hydrates a Money value object from an integer', function () {
    $cast  = new MoneyCast('GBP');
    $money = $cast->get(new Invoice, 'total', 4999, []);

    expect($money)->toBeInstanceOf(Money::class)
        ->and($money->amount)->toBe(4999)
        ->and($money->currency)->toBe('GBP');
});

it('persists a Money value object as an integer', function () {
    $cast  = new MoneyCast('GBP');
    $raw   = $cast->set(new Invoice, 'total', new Money(4999, 'GBP'), []);

    expect($raw)->toBe(4999);
});

No database, no HTTP — pure logic.

Key Takeaways

  • Implement CastsAttributes for bidirectional casts; use CastsInboundAttributes when you only need write-time normalisation.
  • Constructor arguments on the cast class enable per-column configuration via the ClassName:arg syntax.
  • Value objects returned by casts should be readonly final classes — immutability prevents subtle bugs when the same instance is reused across hydrated models.
  • Cast classes are independently unit-testable; keep them free of Eloquent or service container dependencies.
  • For JSON-backed composites, always validate the decoded array shape before constructing the value object to surface data corruption early.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a custom cast return null when the column is nullable?
Yes. In your `get` method, check `if ($value === null) return null;` before constructing the value object. Declare the return type as `?Money` and Eloquent will pass the null through without calling the cast when the column is null — but only if you guard explicitly, since Eloquent does invoke the cast even for null values.
Q02 How do I make a cast work with Eloquent's `->toArray()` and API resources?
Implement the `JsonSerializable` interface on your value object. Eloquent's `toArray` calls `jsonSerialize()` on cast values that implement it, so your `Money` object can serialize to `{"amount":4999,"currency":"USD"}` automatically without extra transformer logic in resources.
Q03 Is there a performance cost to using many custom casts on a model?
The overhead is negligible for typical models. Each cast is resolved once per hydration cycle via a lightweight class instantiation. The bigger concern is ensuring your value object constructors stay cheap — avoid I/O or service lookups inside a cast.

Continue reading

More Articles

View all