jessarcher/laravel-castable-data-transfer-object
A Laravel package that lets Eloquent models cast attributes directly to data transfer objects, keeping structured data typed and clean while handling JSON serialization automatically. Ideal for replacing loose arrays with explicit DTO classes.
This package provides a single trait, CastableDataTransferObject, that lets an Eloquent attribute be represented as a typed DTO while being persisted as JSON in one database column. It's the smallest possible bridge between "I have a JSON blob column" and "I have a real object with typed properties."
Install
composer require jessarcher/laravel-castable-data-transfer-object
v2.3.0 adds Laravel 10.x support (via laravel-shift); the package is a drop-in for Laravel 8/9 projects as well. No new config, no service provider, no migrations shipped.
Where to look first
src/CastableDataTransferObject.php — the whole package is essentially one trait implementing CastsAttributes. Read it in two minutes; you'll know exactly what it does and doesn't do.tests/ — shows the hydration contract (how the JSON array becomes constructor arguments) and the null/empty cases.First use case
Replace a loose array cast on something like a settings or address column:
// app/Data/Address.php
namespace App\Data;
use JessArcher\CastableDataTransferObject\CastableDataTransferObject;
class Address
{
use CastableDataTransferObject;
public function __construct(
public string $street,
public string $city,
public string $postcode,
public ?string $country = null,
) {}
}
// migration
$table->json('address')->nullable();
// model
protected $casts = [
'address' => \App\Data\Address::class,
];
$user->address = new Address('1 Main St', 'Springfield', '12345');
$user->save();
$user->address->city; // "Springfield" — a typed object, not an array
DTO-per-column, one column per DTO. The convention is a single json column per DTO. Don't try to spread DTO properties across real columns with this package — for that, use a custom CastsAttributes or accessors/mutators.
Pair it with spatie/laravel-data. The two compose well: spatie's Data object gives you validation rules, from()/toArray(), collections and nested data objects, and this trait gives you the Eloquent cast. This is the most common day-to-day setup:
class Address extends \Spatie\LaravelData\Data
{
use CastableDataTransferObject;
public function __construct(
public string $street,
public string $city,
public string $postcode,
) {}
}
// validation lives with the DTO
Address::validate($request->all());
Treat the DTO as immutable and reassign. Because the DTO is rebuilt from JSON on each access, mutating a property in place does not mark the model dirty. Always reassign:
$address = $user->address;
$address->city = 'Shelbyville';
$user->address = $address; // this is what triggers dirty tracking
$user->save();
A convenient pattern is a with()-style copy method on the DTO itself.
Validate at the boundary, not in the cast. Put rules in a FormRequest or in the DTO's rules() if you're using spatie/laravel-data. The cast will happily hydrate whatever is in the column; it is not a validator.
Serialization for APIs. If the DTO is passed straight through to response()->json() or the model's toArray(), the object must be serializable. With spatie/laravel-data it already is (Arrayable/JsonSerializable). With a hand-rolled DTO, implement JsonSerializable or add toArray() and normalize in an API Resource:
return UserResource::make($user)->additional([
'address' => $user->address?->toArray(),
]);
Nested DTOs are manual. JSON decoding produces nested arrays, not nested objects. Either map them in the constructor, or use spatie/laravel-data's nested Data casting on the property types, which handles it for you.
Custom cast subclass when you need behaviour. Need to encrypt the column or normalise casing on write? Subclass the DTO's cast or define your own CastsAttributes that delegates to the DTO, rather than adding conditional logic inside the DTO.
Testing. Assert against the raw JSON, since the cast makes the attribute look like an object to the model:
$this->assertDatabaseHas('users', [
'address->city' => 'Springfield',
]);
and use $model->getRawOriginal('address') when you want to inspect the stored payload in a test or a debug session.
No dirty tracking inside the DTO. This is the number-one source of silent bugs: $user->address->city = 'X'; $user->save(); persists nothing. Reassign the whole DTO.
Constructor keys must match the JSON keys. Hydration builds the DTO from the decoded array (typically unpacked into constructor arguments). If you store post_code but the constructor parameter is $postcode, you get a TypeError/ArgumentCountError at read time, not at write time — which means bad data can sit in the database unnoticed until something reads it. Keep DTO property names aligned with the stored keys, and add a round-trip test.
null handling needs care. Laravel skips casting when the stored value is null, so $user->address is null for an absent column — which is usually what you want. But writing null only works cleanly if the column is nullable; otherwise you'll get an empty-object JSON payload or a DB error. Type the property as ?Address in your own code and guard accordingly.
Querying is limited to JSON path operators. Since it's one column, where('address->city', 'Springfield') works on MySQL/Postgres, but you lose per-field indexes, foreign keys, and cheap ORDER BY. If a field needs to be filtered or sorted on frequently, promote it to a real column and keep only the long tail in the DTO.
toArray() on the model may not do what you expect. The casted attribute is returned as an object before serialization; depending on the DTO's interfaces you'll get either a nested array (good) or an object serialized by its public properties (usually fine, but surprising for assertJson). Lock this down with a test on the JSON response shape.
Column type: prefer json over text. On MySQL, json gives you validation-at-the-DB and the -> operators for free. On SQLite (tests) both behave the same, so don't rely on DB-level validation in tests.
Debugging hydration failures. When a cast blows up you get an exception from the DTO constructor, not from Eloquent. Reach for $model->getRawOriginal('address') or $model->getAttributes() to see the stored string, and reproduce with new Address(...json_decode($raw, true)) to isolate whether it's the data or the mapping.
Performance in loops. Each $model->address access rehydrates the DTO from the raw attribute. In a tight loop over thousands of models, assign to a local variable once ($address = $model->address) rather than reading the attribute repeatedly.
Upgrade note (v2.3.0). Purely additive — Laravel 10 compatibility, no API changes, no deprecations. If you're on Laravel 10 and were pinning v2.2.1 or using a fork/dev constraint, bump to ^2.3 and you're done. It's also the version to require if you're planning a Laravel 11 upgrade path, since it's the most recently maintained tag.
Extension points. The trait is small enough to copy if you need different semantics — for example, storing encrypted JSON, or hydrating via a static fromArray() instead of the constructor. Doing so is often cleaner than fighting the cast's assumptions.
How can I help you explore Laravel packages today?