The Problem with Native Array and Object Casts
Laravel's default 'array' and 'json' casts let you store arrays in database columns, but they leave your domain model exposed. You end up with unstructured associative arrays sprinkled throughout your controllers, jobs, and services. Array keys get misspelled, null checks proliferate, and business logic that should belong to the data itself gets copy-pasted across your repository.
Native AsArrayObject or AsStringable classes introduced in earlier Laravel versions help slightly by wrapping data in standard library objects. However, they don't solve domain validation or strict type guarantees. If you pass an invalid status string or negative balance into an ArrayObject, Eloquent accepts it without complaint and writes invalid data straight to MySQL or PostgreSQL.
Custom casts give us full control over the boundary between the database driver and our application. Using PHP 8.3 typed properties and readonly classes, we can convert raw database values into immutable value objects that enforce valid state by design.
Building Pure Value Objects with CastsAttributes
Let's take a common domain requirement: handling monetary values across multiple currencies. Storing floating-point numbers in a database leads to rounding errors, while storing a plain integer amount loses context unless currency is tracked alongside it. A dedicated Money value object coupled with a custom cast solves both issues cleanly.
Here is our PHP 8.3 Money class. It enforces non-negative integer amounts (represented in cents or smallest currency units) and ISO currency codes:
namespace App\ValueObjects;
use InvalidArgumentException;
readonly class Money
{
public function __construct(
public int $amount,
public string $currency = 'USD'
) {
if ($this->amount < 0) {
throw new InvalidArgumentException('Money amount cannot be negative.');
}
if (strlen($this->currency) !== 3) {
throw new InvalidArgumentException('Currency must be a 3-letter ISO code.');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Cannot add different currencies.');
}
return new self($this->amount + $other->amount, $this->currency);
}
public function toArray(): array
{
return [
'amount' => $this->amount,
'currency' => $this->currency,
];
}
}Now we implement Illuminate\Contracts\Database\Eloquent\CastsAttributes. This contract requires two methods: get() for hydrating the model attribute from the database, and set() for converting the value object back into a database-storable format.
namespace App\Casts;
use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class MoneyCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
if (is_null($value)) {
return null;
}
$data = json_decode($value, true);
if (!is_array($data) || !isset($data['amount'], $data['currency'])) {
return null;
}
return new Money(
amount: (int) $data['amount'],
currency: (string) $data['currency']
);
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (is_null($value)) {
return null;
}
if (!$value instanceof Money) {
throw new InvalidArgumentException('Attribute must be an instance of Money.');
}
return json_encode($value->toArray());
}
}Attach this cast to your Eloquent model using the casts() method introduced in recent Laravel releases:
protected function casts(): array
{
return [
'price' => MoneyCast::class,
];
}Encrypted JSON Attributes with Strong Types
Laravel ships with an 'encrypted:array' cast, but it suffers from the same unstructured data flaws as standard array casts. When storing sensitive data like API credentials, integration tokens, or user security settings in a JSON column, you want both application-layer encryption and explicit property typing.
Writing a custom encrypted cast lets you run structural checks and default value fallbacks before encryption occurs during writes, and immediately after decryption during reads. If payload structure changes between app deployments, your cast handles schema migrations gracefully inside the model layer.
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Contracts\Encryption\DecryptException;
use InvalidArgumentException;
class EncryptedSettingsCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
{
if (is_null($value)) {
return null;
}
try {
$decrypted = Crypt::decryptString($value);
$data = json_decode($decrypted, true);
return is_array($data) ? $data : [];
} catch (DecryptException $e) {
return [];
}
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (is_null($value)) {
return null;
}
if (!is_array($value)) {
throw new InvalidArgumentException('Settings attribute must be an array.');
}
$jsonPayload = json_encode($value);
return Crypt::encryptString($jsonPayload);
}
}Mapping One Value Object to Multiple Columns
Sometimes storing a value object inside a single JSON column isn't ideal for database indexing or querying. You might want amount stored in an integer column named price_amount and currency in a string column named price_currency. Eloquent custom casts handle multi-column mapping out of the box when your set() method returns an associative array matching column names instead of a single scalar string.
When you return an array from set(), Eloquent merges those returned keys directly into the model's underlying raw attributes array:
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
if (is_null($value)) {
return [
'price_amount' => null,
'price_currency' => null,
];
}
if (!$value instanceof Money) {
throw new InvalidArgumentException('Expected Money instance.');
}
return [
'price_amount' => $value->amount,
'price_currency' => $value->currency,
];
}In your get() implementation, you read from the fourth parameter ($attributes) to reconstruct the object:
public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
$amount = $attributes['price_amount'] ?? null;
$currency = $attributes['price_currency'] ?? null;
if (is_null($amount) || is_null($currency)) {
return null;
}
return new Money((int) $amount, (string) $currency);
}Handling Dirty State and Immutability Gotchas
One critical pitfall bites developers when using custom casts with mutable objects: Eloquent's dirty state tracking relies on strict value comparison or serialization hashes. If your value object is mutable and you alter an internal property directly (for instance, $user->balance->amount = 500), Eloquent will not detect that the model attribute changed! Calling $user->isDirty('balance') returns false, and running $user->save() executes no SQL UPDATE query.
This is why value objects should always be designed as immutable classes using PHP 8.3 readonly flags. When a value object needs modification, return a new instance of the class (as demonstrated in our Money::add() method). Reassigning the attribute on the model (e.g., $user->balance = $user->balance->add($otherMoney)) triggers Eloquent's internal __set() magic method, running your cast's set() logic and correctly marking the attribute as dirty.
JSON Serialization and API Resources
When returning Eloquent models from Next.js 16 or React 19 backend routes, models get transformed into JSON using json_encode() or standard API resource classes. By default, PHP standard objects will serialize public properties, but custom value objects might expose internal state you don't want sent over the wire.
To explicitly control how custom cast attributes serialize to JSON, implement the Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes interface on your cast class, or implement PHP's built-in JsonSerializable contract on your Value Object class. Implementing JsonSerializable on the value object itself is cleaner because it ensures consistent output regardless of whether the object is transformed through Eloquent, array casting, or standalone resource classes.
Simplifying Models with the Castable Interface
Instead of explicitly binding MoneyCast::class inside model casts() arrays across dozens of models, you can implement Illuminate\Contracts\Database\Eloquent\Castable directly on your Value Object. This tells Eloquent which cast class belongs to the Value Object automatically.
namespace App\ValueObjects;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use App\Casts\MoneyCast;
readonly class Money implements Castable
{
public function __construct(
public int $amount,
public string $currency = 'USD'
) {}
public static function castUsing(array $arguments): CastsAttributes
{
return new MoneyCast(...$arguments);
}
}With this contract implemented, your model cast declaration simplifies to using the Value Object class name directly: 'price' => Money::class. This reduces boilerplate across large codebases and groups your domain object with its persistence logic cleanly.
Using custom casts effectively shifts business logic enforcement away from controller validation rules and places it right at the persistence boundary. Your database records stay clean, your domain objects maintain strict invariants, and your Eloquent models remain readable.












