Custom Eloquent Casts: Encapsulating Domain Logic Inside Model Attributes
#laravel #eloquent #domain-driven-design #php

Custom Eloquent Casts: Encapsulating Domain Logic Inside Model Attributes

3 min read Mohamed Said Mohamed Said

Why Custom Casts Beat Accessor/Mutator Pairs

Before Laravel 8 introduced CastsAttributes, the standard approach was a getXAttribute / setXAttribute pair. That works, but it scatters transformation logic across two methods, makes the intent implicit, and is impossible to reuse across models. A custom cast is a self-contained class: one place to read, one place to write, and trivially injectable into any model.

The Interface Contract

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

/**
 * @implements CastsAttributes<Money, array{amount:int,currency:string}>
 */
class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        if (is_null($value)) {
            return Money::zero();
        }

        $decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);

        return new Money(
            amount: $decoded['amount'],
            currency: Currency::from($decoded['currency'])
        );
    }

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

        throw new \InvalidArgumentException('Value must be a Money instance.');
    }
}

The generic annotation on the @implements docblock is picked up by PHPStan and Psalm, giving you typed attribute access without any extra stubs.

Registering the Cast

class Order extends Model
{
    protected $casts = [
        'total' => MoneyCast::class,
    ];
}

Now $order->total is always a Money object. No defensive instanceof checks in your service layer.

Parameterised Casts

Sometimes you need the cast to behave differently per attribute — for example, rounding precision. Pass arguments via the colon syntax:

protected $casts = [
    'price'    => MoneyCast::class . ':2',
    'tax_rate' => MoneyCast::class . ':4',
];

Receive them in the constructor:

class MoneyCast implements CastsAttributes
{
    public function __construct(protected int $precision = 2) {}

    // get / set use $this->precision
}

Inbound-Only Casts

If you only need to transform on write (e.g., hashing a PIN), implement CastsInboundAttributes instead. It has only a set method, which makes the intent explicit and prevents accidental reads of the raw value.

use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;

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

Testing Your Cast in Isolation

Because the 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 Order();

    $json = $cast->set($model, 'total', new Money(1999, Currency::GBP), []);
    $back = $cast->get($model, 'total', $json, []);

    expect($back->amount)->toBe(1999)
        ->and($back->currency)->toBe(Currency::GBP);
});

No factories, no migrations, no HTTP — just fast, focused assertions.

Handling Null Gracefully

Eloquent passes null to get when the column is NULL. Always decide explicitly: return a null object, throw, or return null and mark the property nullable in your type hint. Returning a null object (like Money::zero()) is usually the safest choice for arithmetic-heavy domains.

When Not to Use a Cast

Casts are evaluated on every attribute access. If your value object is expensive to construct (e.g., it calls a service or parses a large blob), consider lazy construction or caching the result in a model property instead.

Takeaways

  • CastsAttributes gives you a single, reusable class for bidirectional transformation — no more scattered accessor/mutator pairs.
  • Parameterised casts via the colon syntax keep one class flexible across multiple attributes.
  • Use CastsInboundAttributes for write-only transformations like hashing.
  • Casts are plain PHP classes: unit-test them without a database for fast feedback loops.
  • Always handle null explicitly to avoid silent type errors downstream.
  • Avoid expensive operations inside get; casts run on every property read.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a custom cast return null instead of a value object?
Yes. If the column is nullable and a null object pattern does not fit your domain, simply return null from `get` and annotate the property type as nullable. Just be consistent so callers always know what to expect.
Q02 Do custom casts work with Eloquent's `isDirty` and `getChanges` methods?
Yes, but Eloquent compares the raw stored value, not the cast object. If you need dirty-checking on the value object level, override `castForComparison` or compare objects yourself in a model observer.
Q03 Is there a performance cost to using custom casts on frequently accessed attributes?
The cast's `get` method runs on every attribute access unless you cache the result. For lightweight value objects the overhead is negligible, but for expensive construction consider storing the result in a model property after the first access.

Continue reading

More Articles

View all