Contextual Eloquent Casts: Custom Cast Classes, Value Objects, and Inbound-Only Transforms
#laravel #eloquent #value-objects #casts #php

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

1 min read Mohamed Said Mohamed Said

Why Built-in Casts Stop Being Enough

Laravel ships with a solid set of primitive casts — integer, boolean, encrypted, AsCollection, and friends. They cover 80% of daily work. The remaining 20% is where bugs hide: money stored as integers but displayed as decimals, coordinates serialised as JSON but consumed as typed objects, or phone numbers that must be normalised on write and formatted on read.

Custom cast classes solve all three cases without polluting model methods or observers.


Anatomy of a Custom Cast Class

A cast class implements CastsAttributes. The two methods are get (hydrate from DB) and set (dehydrate to DB).

<?php

namespace App\Casts;

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

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

    /** @param int|null $value stored as minor units */
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
    {
        return $value === null ? null : new Money((int) $value, $this->currency);
    }

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

Attach it with constructor arguments using colon syntax:

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

The cast receives 'EUR' as the first constructor argument automatically. No service-container magic needed.


Inbound-Only Casts

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

use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;

class E164PhoneCast implements CastsInboundAttributes
{
    public function set(Model $model, string $key, mixed $value, array $attributes): string|null
    {
        if ($value === null) return null;
        // Strip everything except digits and leading +
        return preg_replace('/[^\d+]/', '', (string) $value);
    }
}

Reading the attribute returns the raw DB string — no hydration overhead, no accidental re-formatting.


Hydrating Composite Value Objects

Some value objects span multiple columns. The set method can return an array to write several columns at once:

class CoordinatesCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Coordinates
    {
        if ($attributes['lat'] === null) return null;
        return new Coordinates((float) $attributes['lat'], (float) $attributes['lng']);
    }

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

Register it on a virtual key — the key does not need to be a real column:

protected function casts(): array
{
    return ['location' => CoordinatesCast::class];
}

Now $venue->location returns a Coordinates object, and $venue->location = new Coordinates(51.5, -0.1) writes both lat and lng.


Testing Cast Classes in Isolation

Because cast classes are plain PHP, you can unit-test them without a database:

it('normalises phone to E164 on set', function () {
    $cast  = new E164PhoneCast();
    $model = new class extends Model {};

    $result = $cast->set($model, 'phone', '+1 (800) 555-0199', []);

    expect($result)->toBe('+18005550199');
});

it('returns null when value is null', function () {
    $cast  = new E164PhoneCast();
    $model = new class extends Model {};

    expect($cast->set($model, 'phone', null, []))->toBeNull();
});

No factories, no migrations, no HTTP layer. Fast feedback.


Takeaways

  • Implement CastsAttributes for bidirectional transforms; use CastsInboundAttributes when reads need no processing.
  • Pass constructor arguments via the ClassName:arg1,arg2 colon syntax — no extra boilerplate.
  • Return an array from set() to hydrate composite value objects that span multiple columns.
  • Cast classes are plain PHP objects — test them directly without touching the database.
  • Keep value objects immutable and cast classes stateless; side effects in casts cause subtle bugs under Octane worker reuse.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a custom cast class depend on services from the Laravel container?
Not directly via the constructor when using the colon-argument syntax, because Laravel resolves cast classes with simple instantiation. If you need a service, resolve it inside the method body via app() or inject it through a static factory method on the cast class.
Q02 Does returning an array from set() work with mass assignment and fill()?
Yes. When you call fill() or assign the virtual attribute directly, Eloquent calls set() and merges the returned array into the model's attributes before saving. The virtual key itself is never written to the database.
Q03 How do composite casts interact with dirty-checking and isDirty()?
Eloquent tracks the individual underlying columns (lat, lng) as dirty, not the virtual key. So isDirty('location') returns false, but isDirty('lat') or isDirty('lng') will return true after a change. Account for this in observers or event listeners.

Continue reading

More Articles

View all