Casting JSON columns to DTOs with Laravel's custom casts

March 10, 20205 min read

Since custom casts have finally arrived with Laravel 7, we can take full advantage of this new feature to get full autocomplete and type checking of JSON columns.

To achieve this, we can use data-transfer-object by Spatie, since native DTOs aren’t supported in PHP (I’d recommend taking a look at it first, before reading this post). It can be installed by running:

composer require spatie/data-transfer-object

Default JSON columns casting

Having the following migration:

final class CreateUsersTable extends Migration
{
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->json('address')->nullable();
        });
    }
}

This is how we can define the deserialization method for the address column in our User model:

final class User extends Authenticatable
{
    protected $casts = [
        'address' => 'array',
    ];
}

However, our IDE can’t tell us what’s inside $user->address. In addition, there is no validation while saving something into the address column.

Custom JSON to DTO cast

Custom casts are just classes that implement the CastsAttributes interface, which requires us to define the get and set methods.

Having the following user address DTO:

<?php

namespace App\Dto;

use Spatie\DataTransferObject\DataTransferObject;

final class UserAddress extends DataTransferObject
{
    public string $city;

    public string $street;
}

This is how our address cast would look like:

<?php

namespace App\Casts;

use App\Dto\UserAddress;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

final class UserAddressCast implements CastsAttributes
{
    /**
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return UserAddress|null
     */
    public function get($model, $key, $value, $attributes): ?UserAddress
    {
        if (! $value) {
            return null;
        }

        return new UserAddress(json_decode($value, true));
    }

    /**
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return string
     * @throws \Exception
     */
    public function set($model, $key, $value, $attributes): string
    {
        if (! $value instanceof UserAddress) {
            throw new \Exception("The provided value must be an instance of ".UserAddress::class);
        }

        return json_encode($value->toArray());
    }
}

Basically inside the get method, we are decoding the JSON column and return a freshly initialized UserAddress DTO. However, if the column is null (it might be nullable in our database), we’re returning null.

Inside the set method, we’re just checking if we’ve received an instance of UserAddress and serialize the DTO back to JSON using the toArray method, provided by DataTransferObject, which is the parent of UserAddress.

All that’s left is to change array to UserAddressCast::class in our user model:

final class User extends Authenticatable
{
    protected $casts = [
        'address' => UserAddressCast::class,
    ];
}

And now this is how we would fill user’s address:

// If address is null initially:
$user->address = new UserAddress([
    'city' => 'Cool City',
    'street' => 'Nice Street',
]);

// If address is already filled:
$user->address->city = 'Another Cool City';

$user->address->street = 'Another Nice City';

Same goes in factories:

$factory->define(User::class, function (Faker $faker) {
    return [
        'address' => new UserAddress([
          'city' => 'Cool City',
          'street' => 'Nice Street',
        ]),
    ];
});

Automating JSON to DTO casting

Wouldn’t it be a pain if we’ve had to define such long casting classes for each DTO? Well, we can define an abstract DTO cast which will be extended in the future:

<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Spatie\DataTransferObject\DataTransferObject;

abstract class DTOCast implements CastsAttributes
{
    /**
     * @return string
     */
    abstract protected function dtoClass(): string;

    /**
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return DataTransferObject|null
     */
    public function get($model, $key, $value, $attributes): ?DataTransferObject
    {
        if (!$value) {
            return null;
        }

        $dtoClass = $this->dtoClass();

        return (new $dtoClass)(json_decode($value, true));
    }

    /**
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return string
     * @throws \Exception
     */
    public function set($model, $key, $value, $attributes): string
    {
        $dtoClass = $this->dtoClass();

        if (!$value instanceof $dtoClass) {
            throw new \Exception("The provided value must be an instance of ".$dtoClass);
        }

        return json_encode($value->toArray());
    }
}

The get and set methods have the exact same logic as in the UserAddressCast example above. However, notice that we’ve defined an abstract method dtoClass which must return a DTO class name.

Now our UserAddressCast becomes this:

<?php

namespace App\Casts;

use App\Dto\UserAddress;

final class UserAddressCast extends DTOCast
{
    protected function dtoClass(): string
    {
        return UserAddress::class;
    }
}

Getting autocomplete to work

The only way I know to get proper autocompletion in Laravel, is by using the awesome Laravel IDE Helper by Barry vd. Heuvel. It can be installed by running:

composer require --dev barryvdh/laravel-ide-helper

Now automatic phpDocs for models can be generated by running this in your project:

php artisan ide-helper:models

laravel-ide-helper will mark the address column as an UserAddressCast type, which in reality must be an UserAddress type, but fortunately, this package provides a configurable option for casting types overriding.

Publish the config by running:

php artisan vendor:publish --provider="Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider" --tag=config

It will publish the config to config/ide-helper.php.

Now, we can override the casting class with our real DTO class, using the type_overrides option inside the published config:

'type_overrides' => [
    '\\'.UserAddressCast::class => '\\'.UserAddress::class,
],

After updating the config, regenerate the phpDocs and that’s it!

Autocompletion


Keep in mind that I don’t recommend storing the “address” as a JSON column. This minimalistic example is just for demonstration purposes.


Thanks for reading this post! Subscribe below and get notified when new posts will be released or follow me on Twitter (@sandulat).


Join the Newsletter

Subscribe to get my latest posts by email.

I respect your privacy. Unsubscribe at any time.