October 19, 2020

Casting JSON Columns to Value Objects with Laravel

Have you ever wanted to access an attribute of an Eloquent model as a value object, similar to how Eloquent lets us work with dates via Carbon?

$user->address->calculateDistance($otherUser->address);

Most value objects have multiple attributes. That's part of what separates them from primitive types like strings and integers. Some may have have special formats that allow us to represent the individual attributes as a single string. For example, a date can represent the year, month, and day as Y-m-d but we can still pull it apart if needed, and the database knows how to query the parts individually.

We may not always be so lucky with our value objects though. So we may be tempted to create our own conventions. However, the database won't be able to query the parts easily, and depending on the number of type of attributes, it could get unwieldy.

We could create dedicated columns in the database for each attribute. The Laravel docs has an excellent example of how we can cast to and from multiple columns into a single value object.

But in some cases we may want a nested structure, or maybe we have a lot of optional fields that we don't want cluttering the table structure. Maybe we have a collection of items that aren't deserving of their own table. There are plenty of reasons why you may want to consider a JSON column.

JSON columns effectively give us the benefits (and downsides) of a NoSQL/document-based database inside our relational database. And modern database engines can index and natively query inside these JSON structures quite well.

So what are our options for working with JSON columns in Laravel?

You may be familiar with the following built-in cast:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

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

This will automatically cast an array (associative or numeric) to JSON, and back again. Very handy!

You may also be familiar with:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $casts = [
        'address' => 'object'
    ];
}

This does the same thing, but for a stdClass object. This is cool too, but in practice I've found an associative array is often easier to work with.

Now imagine casting to an instance of a specific value object class via Laravel's custom casts:

namespace App\Models;

use App\Casts\Address;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $casts = [
        'address' => Address::class,
    ];
}

Now things are getting pretty nice.

But take that another step and make the value object class castable by implementing the Castable interface:

namespace App\Values;

use App\Casts\Address as AddressCast;
use Illuminate\Contracts\Database\Eloquent\Castable;

class Address implements Castable
{
    // ...

    public static function castUsing(array $arguments)
    {
        return AddressCast::class;
    }
}

And now we can cast to the value object class itself, instead of the custom cast, which just feels a bit nicer:

use App/Values/Address;

class User extends Model
{
    protected $casts = [
        'address' => Address::class,
    ];
}

Awwww yeah!

As for the cast logic, serializing between JSON and arrays or stdClass objects is pretty straight-forward with json_encode() and json_decode(). Our custom cast could wrap these and instantiate our value object and things would be pretty sweet.

How can we take it even further, beyond the docs?

Do you know what makes working with array structures in PHP even nicer? Spatie's Data Transfer Object (DTO) package, that's what!

composer require spatie/data-transfer-object

This unlocks a few extra things:

  • The data is validated to make sure it conforms to a defined structure and types, otherwise an exception is thrown.
  • It handles casting the raw array to and from an instance of our custom class.

We just need to define the properties:

namespace App\Values;

use App\Casts\Address as AddressCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Spatie\DataTransferObject\DataTransferObject;

class Address extends DataTransferObject implements Castable
{
    public string $street;
    public string $suburb;
    public string $state;

    public static function castUsing(array $arguments)
    {
        return AddressCast::class;
    }
}

I like to store these in App\Values because I'll typically be adding domain-specific methods that make it more like a value object than a plain DTO. Feel free to store it anywhere you like, such as App\DataTransferObjects or even in the same directory as your models.

Also note that we're not limited to primitive types here - we can include other classes including nested data transfer objects. We can also mark properties as nullable to make them optional. Spatie's package even allows us to define arrays of things and also unions types! Some of which will be native in PHP 8. Check out the data transfer object package docs to see how these can all be defined.

At this point, the Address custom cast might look something like this:

namespace App\Casts;

use App\Values\Address as AddressValue;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class Address implements CastsAttributes
{
    /**
     * Cast the stored value to an Address.
     */
    public function get($model, $key, $value, $attributes)
    {
        /*
         * We'll need this to handle nullable columns
         */
        if (is_null($value)) {
            return;
        }

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

    /**
     * Prepare the given value for storage.
     */
    public function set($model, $key, $value, $attributes)
    {
        /*
         * We'll need this to handle nullable columns
         */
        if (is_null($value)) {
            return;
        }

        /*
         * Allow the user to pass an array instead of the value object itself.
         * Similar to how we can pass a date string or a Carbon/DateTime object with a date cast.
         */
        if (is_array($value)) {
            $value = new AddressValue($value);
        }

        if (! $value instanceof AddressValue) {
            throw new InvalidArgumentException('Value must be of type Address, array, or null');
        }

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

But notice how there's nothing really specific to the Address value object in our custom cast, except for the class name itself. If we have multiple data transfer objects, we could take this one more step by specifying the DTO class via the constructor so the cast is reusable!

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class DataTransferObject implements CastsAttributes
{
    protected string $class;

    /**
     * @param string $class The DataTransferObject class to cast to
     */
    public function __construct(string $class)
    {
        $this->class = $class;
    }

    /**
     * Cast the stored value to the configured DataTransferObject.
     */
    public function get($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return;
        }

        return new $this->class(json_decode($value, true));
    }

    /**
     * Prepare the given value for storage.
     */
    public function set($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return;
        }

        if (is_array($value)) {
            $value = new $this->class($value);
        }

        if (! $value instanceof $this->class) {
            throw new InvalidArgumentException("Value must be of type [$this->class], array, or null");
        }

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

And now our Address value object's castUsing() method can be updated like so:

public static function castUsing(array $arguments)
{
    return new DataTransferObject(Address::class);
}

The only other refactor I like is to create a CastableDataTransferObject class that my Address class and others can extend. That way they don't need to worry about making themselves castable:

namespace App\Values;

use App\Casts\DataTransferObject as DataTransferObjectCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Spatie\DataTransferObject\DataTransferObject;
use function Safe\json_decode;
use function Safe\json_encode;

abstract class CastableDataTransferObject extends DataTransferObject implements Castable
{
    public static function castUsing()
    {
        return new DataTransferObjectCast(static::class);
    }

    public function toJson()
    {
        return json_encode($this->toArray());
    }

    public static function fromJson($json)
    {
        return new static(json_decode($json, true));
    }
}

The toJson() and fromJson() are a nicer extra touch to encapsulate the DTO serialization instead of putting it in the cast. Also be sure to check out Safe PHP if you haven't already.

Our Address value object class is now super tidy:

namespace App\Values;

class Address extends CastableDataTransferObject
{
    public string $street;
    public string $suburb;
    public string $state;
}

And remember that we can cast directly to this class in our Eloquent model:

protected $casts = [
    'address' => Address::class,
];

When all is said and done, we should only need the following classes:

app/Casts/DataTransferObject.php
app/Values/CastableDataTransferObject.php
app/Values/Address.php

And we can now do cool stuff like:

User::create([
    'name' => 'Emmett Brown',
    'address' => [
        'street' => '1640 Riverside Drive',
        'suburb' => 'Hill Valley',
        'state' => 'California',
    ]
])
$residents = User::where('address->suburb', 'Hill Valley')->get();

And finally, we can fully realise our value object by creating a rich API for our address-specific methods on our Address class, which is now a hybrid data transfer object and value object. A data transfer value object.

$user->address->toMapUrl();

$user->address->getCoordinates();

$user->address->getPostageCost($sender);

$user->address->calculateDistance($otherUser->address);

If you want to use the CastableDataTransferObject in your project, I have created a package so you only need to bring your own classes!