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

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

1 min read Mohamed Said Mohamed Said

Why Built-in Casts Fall Short

Laravel ships with a solid set of primitive casts — integer, boolean, array, encrypted, AsCollection, and so on. They cover 80% of everyday work. The remaining 20% is where models start accumulating getXAttribute / setXAttribute pairs, helper methods, and subtle inconsistencies between what goes into the database and what comes out.

Custom cast classes solve this cleanly. They are first-class objects, testable in isolation, and composable across multiple models.

Anatomy of a Custom Cast

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

<?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 get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money(
            amount: (int) $value,
            currency: $attributes['currency'] ?? 'USD',
        );
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if (! $value instanceof Money) {
            $value = Money::fromFloat((float) $value, $attributes['currency'] ?? 'USD');
        }

        return [
            $key => $value->getAmount(),
            'currency' => $value->getCurrency(),
        ];
    }
}

Notice set returns an array — this is how a single cast writes to multiple columns atomically. Eloquent merges that array back into the dirty attributes before persisting.

Wiring It Up

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

// Usage
$order->total;           // Money instance
$order->total->format(); // '$12.50'
$order->total = Money::fromFloat(25.00, 'EUR'); // triggers set()
$order->save();

Parameterised Casts

When you need runtime configuration, implement CastsAttributes and accept constructor arguments. Pass them via the cast string.

class RoundedDecimalCast implements CastsAttributes
{
    public function __construct(private int $precision = 2) {}

    public function get(Model $model, string $key, mixed $value, array $attributes): float
    {
        return round((float) $value, $this->precision);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): float
    {
        return round((float) $value, $this->precision);
    }
}
protected $casts = [
    'tax_rate' => RoundedDecimalCast::class . ':4',
    'display_price' => RoundedDecimalCast::class . ':2',
];

Laravel splits on : and passes the remainder as constructor arguments.

Inbound-Only Casts

Sometimes you only need to transform data coming in — hashing a PIN, normalising a phone number, upper-casing a country code. Implement CastsInboundAttributes instead.

use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;

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

Reads return the raw stored value. Writes always pass through set. This is lighter than a full bidirectional cast and signals intent clearly to the next developer.

Casting to Enums vs. Custom Cast Classes

Laravel's built-in enum cast (AsEnum) is perfect for backed enums stored as scalars. Reach for a custom cast class when:

  • The value object wraps multiple columns.
  • You need constructor logic (validation, normalisation) on hydration.
  • The type is not a PHP enum (e.g., a Money, Coordinate, or DateRange value object).

Testing Casts in Isolation

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

it('hydrates a Money value object from integer storage', function () {
    $cast = new MoneyCast();
    $model = new Order();

    $money = $cast->get($model, 'total', 1250, ['currency' => 'USD']);

    expect($money)->toBeInstanceOf(Money::class)
        ->and($money->getAmount())->toBe(1250)
        ->and($money->getCurrency())->toBe('USD');
});

it('writes multiple columns from a Money instance', function () {
    $cast = new MoneyCast();
    $model = new Order();
    $money = new Money(500, 'EUR');

    $result = $cast->set($model, 'total', $money, []);

    expect($result)->toBe(['total' => 500, 'currency' => 'EUR']);
});

No factories, no migrations, no database connection — fast feedback.

Key Takeaways

  • Return an array from set to write multiple columns from one cast.
  • Use constructor parameters (:arg syntax) for reusable, configurable casts.
  • Prefer CastsInboundAttributes when reads need no transformation — it signals intent and avoids unnecessary overhead.
  • Custom casts are plain PHP classes — unit-test them directly without hitting the database.
  • Avoid putting domain validation inside a cast; keep the cast as a translation layer and validate in your action or DTO before the model is touched.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a custom cast write to columns other than the one it is declared on?
Yes. Return an associative array from the `set` method. Eloquent merges every key in that array into the model's dirty attributes, so you can write to `total` and `currency` from a single `MoneyCast` declared on the `total` column.
Q02 When should I use a custom cast instead of Laravel's built-in AsEnum cast?
Use AsEnum for simple backed enums stored as a single scalar. Reach for a custom cast class when the value spans multiple columns, requires constructor-time validation or normalisation, or is a domain value object (Money, Coordinate, DateRange) rather than a PHP enum.
Q03 Do custom cast classes work with Eloquent's `withCasts` runtime method?
Yes. You can pass a cast class name (with optional parameters) to `withCasts` at query time, which is useful when you need a different precision or behaviour for a specific query without changing the model definition.

Continue reading

More Articles

View all