Why Custom Casts Beat Accessors and Mutators
Accessors and mutators (get/set attribute methods) are convenient, but they scatter transformation logic across your model and return plain scalars. A custom cast implements CastsAttributes and gives you a dedicated class that is independently testable, reusable across models, and expressive about what the column actually holds.
The canonical use-case: a money column stored as an integer (cents) that your application always treats as a Money value object.
Defining the Value Object
<?php
namespace App\Domain\Billing\ValueObjects;
final readonly class Money
{
public function __construct(
public readonly int $amount, // cents
public readonly string $currency,
) {}
public function format(): string
{
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \DomainException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
The value object is a plain PHP class — no framework dependencies, fully unit-testable.
Implementing the Cast
<?php
namespace App\Domain\Billing\Casts;
use App\Domain\Billing\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/**
* @implements CastsAttributes<Money, Money>
*/
class MoneyCast implements CastsAttributes
{
public function __construct(
private readonly string $currency = 'USD',
) {}
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
return new Money((int) $value, $this->currency);
}
public function set(Model $model, string $key, mixed $value, array $attributes): int
{
if ($value instanceof Money) {
return $value->amount;
}
return (int) $value;
}
}
The constructor argument makes the cast configurable per-column — a pattern Laravel supports natively.
Registering on the Model
use App\Domain\Billing\Casts\MoneyCast;
class Invoice extends Model
{
protected function casts(): array
{
return [
'subtotal' => MoneyCast::class . ':USD',
'tax' => MoneyCast::class . ':USD',
'total' => MoneyCast::class . ':USD',
];
}
}
Now $invoice->total is always a Money instance — no accidental integer arithmetic leaking into controllers.
Handling JSON Columns with CastsAttributes
For composite value objects backed by a JSON column, serialize to an array in set and reconstruct in get:
public function get(Model $model, string $key, mixed $value, array $attributes): Address
{
$data = json_decode($value, true);
return new Address($data['line1'], $data['city'], $data['postcode']);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return json_encode([
'line1' => $value->line1,
'city' => $value->city,
'postcode' => $value->postcode,
]);
}
Inbound-Only Casts
When you only need transformation on the way in (e.g., normalising a phone number before storage), implement CastsInboundAttributes instead. This signals intent clearly and avoids the overhead of a get path.
class E164PhoneCast implements CastsInboundAttributes
{
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return preg_replace('/[^\d+]/', '', (string) $value);
}
}
Testing the Cast in Isolation
Because CastsAttributes is a plain class, you can unit-test it without booting the framework:
it('hydrates a Money value object from an integer', function () {
$cast = new MoneyCast('GBP');
$money = $cast->get(new Invoice, 'total', 4999, []);
expect($money)->toBeInstanceOf(Money::class)
->and($money->amount)->toBe(4999)
->and($money->currency)->toBe('GBP');
});
it('persists a Money value object as an integer', function () {
$cast = new MoneyCast('GBP');
$raw = $cast->set(new Invoice, 'total', new Money(4999, 'GBP'), []);
expect($raw)->toBe(4999);
});
No database, no HTTP — pure logic.
Key Takeaways
- Implement
CastsAttributesfor bidirectional casts; useCastsInboundAttributeswhen you only need write-time normalisation. - Constructor arguments on the cast class enable per-column configuration via the
ClassName:argsyntax. - Value objects returned by casts should be
readonlyfinal classes — immutability prevents subtle bugs when the same instance is reused across hydrated models. - Cast classes are independently unit-testable; keep them free of Eloquent or service container dependencies.
- For JSON-backed composites, always validate the decoded array shape before constructing the value object to surface data corruption early.