Skip to content
Engineering notes

Kennedy Mutisya

Casting JSON Columns to Value Objects with Laravel

Turn JSON columns into rich value objects using custom casts, the Castable interface, and reusable DTO abstractions.

Have you ever wanted to grab an Eloquent model attribute and have it come back as a rich, typed value object instead of a plain array or stdClass? That is the kind of ergonomics we get with Carbon for dates in Eloquent. JSON columns are the natural answer here, but working with them raw means you are dealing with associative arrays and hoping your keys are spelled right.

Let us walk through a progression, from built-in array casting all the way to a clean, reusable castable value object setup.

The Problem with Plain JSON Columns

Eloquent's $casts property gives you a couple of built-in options:

protected $casts = [
    'address' => 'array',
];

This auto-serializes to and from JSON. Access it and you get an associative array back. Swap 'array' for 'object' and you get a stdClass instead. Both work, but neither gives you any type safety, autocomplete, or the ability to add behaviour directly on the data.

If your address value needs to know how to calculate a postage cost or build a map URL, you end up writing helper functions that take the raw array, which scatters that logic across your codebase instead of keeping it where it belongs.

Custom Casts: First Step

Laravel's custom casts give us a better foundation. You create a class that implements the CastsAttributes interface and define get() and set() methods:

namespace App\Casts;

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

class Address implements CastsAttributes
{
    public function get($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return;
        }

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

    public function set($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return;
        }

        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());
    }
}

Now we cast in the model like this:

use App\Casts\Address;

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

That is already a big step up: we get a typed object instead of an array. But we can do better.

Making the Value Object Castable

Instead of pointing the model at a separate caster class, Laravel lets a class implement the Castable interface and tell the world how to cast itself:

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;
    }
}

Now the model casts directly to the value object class:

use App\Values\Address;

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

That feels cleaner. The value object owns its casting logic instead of relying on an external caster that the developer has to remember to link.

Enter Spatie's DTO Package

Spatie's Data Transfer Object package (superseded by Laravel Data, but still instructive) gives us typed, validated properties:

composer require spatie/data-transfer-object
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;
    }
}

Now if someone tries to set $address->street to an integer, an exception is thrown. The structure is enforced, not assumed.

Making the Caster Reusable

Look at the caster from earlier. Nothing in it is specific to the Address class except the class name reference. If we have multiple value objects, we would be duplicating the same casting logic for each one. Instead, parameterise it:

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class DataTransferObject implements CastsAttributes
{
    protected string $class;

    public function __construct(string $class)
    {
        $this->class = $class;
    }

    public function get($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return;
        }

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

    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());
    }
}

Now your value object's castUsing() becomes:

use App\Casts\DataTransferObject;

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

One general-purpose caster, any DTO class.

An Abstract Castable DTO

We can go one step further and create an abstract base class that any value object can extend. This eliminates the boilerplate of implementing Castable on every class:

namespace App\Values;

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

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

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

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

The toJson() and fromJson() methods keep serialization with the value object. And because they are on the abstract class, every extending value object gets them for free.

Now the Address class is just:

namespace App\Values;

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

The Final Setup

You end up with three files:

app/Casts/DataTransferObject.php          # The generic caster
app/Values/CastableDataTransferObject.php # The abstract base class
app/Values/Address.php                    # Your actual value object

And the model casts are as clean as it gets:

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

Querying JSON columns works natively:

$residents = User::where('address->suburb', 'Hill Valley')->get();

Creating records accepts either an array or the value object directly:

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

Once retrieved, $user->address is a full-fledged value object with the methods you define on it.

Key Takeaways

  • JSON columns are great for nested or optional data, but raw arrays lack safety and expressiveness.
  • Custom casts bridge the gap between the database and typed objects.
  • The Castable interface lets value objects declare how they should be cast without the model knowing the details.
  • A single generic caster serves every DTO in your app. No more one-off caster classes.
  • An abstract base class eliminates the boilerplate so new value objects are trivial to create.

The result is a clean separation: value objects own their shape, their validation, and their behaviour. Eloquent handles persistence. And your application code talks to rich, meaningful objects instead of loose arrays.