Laravel: Casting Eloquent JSON Fields
Learn to cast a JSON column to a DTO in Laravel Eloquent
I have a short post today to cover something I recently used in a project. I had a table using a JSON column and though they are extremely flexible, I like to know what data exists in the column. To accomplish this, I use a simple data transfer object.
In my project, I have a checkout_events
table where I track various options and properties of a user's checkout. It’s primarily for debugging purposes, but maybe one day, it could grow into an event sourcing flow.
In the table, I have a metadata
column and I want to track things like processor
, checkoutSource
, checkoutType
, errorMessage
, and exceptionClass
. As things change or grow, I knew the different properties could change, which is why I opted for a JSON column versus a dedicated column for each property.
To make sure I have the correct data going in and out of the table, I created the DTO below:
<?php
readonly class CheckoutMetadata
{
public function __construct(
public Processor $processor,
public CheckoutSource $checkoutSource,
public CheckoutType $checkoutType,
public ?string $errorMessage = null,
public ?string $exceptionClass = null,
) {}
}
Notice, the $errorMessage
and $exception
properties are nullable since the event won’t always have that metadata.
Now, for the model, we need to add a cast for this:
<?php
class CheckoutEvents extends Model
{
protected function casts(): array
{
return [
'metadata' => CheckoutMetadata::class,
];
}
}
Now, to actually make this work, we could create a Cast
in Laravel as shown in the docs. However, I am going to opt for making the DTO itself be Castable
(docs) and use anonymous cast classes (docs). To achieve this, CheckoutMetadata
needs to implement Illuminate\Contracts\Database\Eloquent\Castable
. The contract requires a castUsing
method that returns a new class instance that implements Illuminate\Contracts\Database\Eloquent\CastsAttributes
:
<?php
class CheckoutMetadata implements Castable
{
public function __construct(
public Processor $processor,
public CheckoutSource $checkoutSource,
public CheckoutType $checkoutType,
public ?string $errorMessage = null,
public ?string $exceptionClass = null,
) {}
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
// Get method is called when getting metadata from the model.
public function get(Model $model, string $key, mixed $value, array $attributes)
{
// Convert the JSON data from the database into an object.
$data = json_decode($attributes[$key]);
return new CheckoutMetadata(
processor: Processor::from($data->processor),
checkoutSource: CheckoutSource::from($data->checkout_source),
checkoutType: CheckoutType::from($data->checkout_type),
errorMessage: data_get($data, 'error_message'),
exceptionClass: data_get($data, 'error_message'),
);
}
// Set method is called when updating the metadata field on the model.
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
// Throw an exception if trying to set a value that is not an instance of CheckoutMetadata.
if (! $value instanceof CheckoutMetadata) {
throw new InvalidArgumentException('A CheckoutMetadata instance is required.');
}
$data = [
'processor' => $value->processor->value,
'checkout_source' => $value->checkoutSource->value,
'checkout_type' => $value->checkoutType->value,
'error_message' => $value->errorMessage,
'exception_class' => $value->exceptionClass,
];
// JSON encode the data to store in the database.
return [$key => json_encode($data)];
}
};
}
}
Quite a lot of new stuff here. The get
method of the anonymous class gets called when we try to get the metadata
property from the model, like $checkoutModel->metadata
. We need to decode the JSON from the database and then instantiate a new CheckoutMetadata
class instead. Now, anytime we call $checkoutModel->metadata
, we get the CheckoutMetadata
class and know what properties we have available.
The set
method is used when we try to update the metadata
property, so: $checkoutModel->metadata = $checkoutMetadata
. Since we always want to require data to be in the form of CheckoutMetadata
, we throw an InvalidArgumentException
if the value passed in is not an instance of CheckoutMetadata
.
Now, our model can be used like the following:
<?php
$metadata = new CheckoutMetadata(
checkoutSource: CheckoutSource::Brick,
checkoutType: CheckoutType::OneTime,
processor: Processor::Braintree
);
$model = new CheckoutEvent();
$model->name = 'New Event';
$model->user_id = 1;
$model->metadata = $metadata;
$model->save();
$model->metadata;
// App\CheckoutMetadata {#2744
// +processor: App\Processor {#2746
// +name: "Braintree",
// +value: 2,
// },
// +checkoutSource: App\CheckoutSource {#2750
// +name: "Brick",
// +value: 2,
// },
// +checkoutType: App\CheckoutType {#2748
// +name: "OneTime",
// +value: 2,
// },
// +errorMessage: null,
// +exceptionClass: null,
// }
In the future, if we wish to add properties that we will track, we can add new optional properties to the DTO and update the anonymous CastAttributes
class. If we want to remove properties, we can just delete them from the DTO and update the anonymous CastsAttributes
class.
For further learning, look into the Arrayable
and JsonSerializable
interfaces in Laravel so you can return the DTO to an expected structure when converting a model to an array or JSON. You can also read about Array / JSON Serialization in Laravel.
I hope you enjoyed this post and learned something new about Eloquent attribute casting and JSON fields. They can be powerful, but it’s important to make sure the data being passed in and out of the field is what you expect. You don’t want it to blow up with countless unknown properties and different structures in each row.
Thanks for reading!
Furthermore, if you’d like to learn more about data transfer objects, read my post Streamlining API Responses in Laravel with DTOs.