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
CastsAttributesfor bidirectional transforms; useCastsInboundAttributeswhen reads need no processing. - Pass constructor arguments via the
ClassName:arg1,arg2colon 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.