Laravel: Casting Eloquent JSON Fields

Sean KegelSean Kegel
4 min read

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.

1
Subscribe to my newsletter

Read articles from Sean Kegel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sean Kegel
Sean Kegel