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, orDateRangevalue 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
setto write multiple columns from one cast. - Use constructor parameters (
:argsyntax) for reusable, configurable casts. - Prefer
CastsInboundAttributeswhen 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.