Laravel Data and Value Objects

Sean KegelSean Kegel
9 min read

Laravel Data and Value Objects

Recently, I was presented with a problem using value objects with the Laravel Data package by Spatie. I have been trying to use value objects a lot more in my code for things like money, emails, phone numbers, etc. When I am working with data from an external API, it is very helpful to convert this data to value objects when I can.

If you haven’t been using value objects or data transfer objects, here are some helpful articles to learn more:

When using my own custom data transfer objects, I can create fromArray and toArray methods to automatically instantiate these value objects. However, Laravel Data provides a lot of nice features out of the box that can help reduce some of the boilerplate code in my data transfer objects. The problem is, I didn’t know the best ways to use Laravel Data to instantiate my value objects. I knew of some of the various features of Laravel Data, like casts and transformers, but had never used them, until now.

In my project, I receive order data from an API. The order data that comes into the application might look something like the following:

{
    "id": 123,
    "user_id": 345,
    "product_id": 678,
    "amount": "10.99",
    "status": "success",
    "processed_at": "2023-09-30T10:00:00+00:00",
    "created_at": "2023-09-28T10:00:00+00:00",
    "updated_at": "2023-09-30T10:00:00+00:00",
}

To model this in Laravel Data, I could have a class like the following:

class OrderData extends Data
{
    public function __construct(
        public int $id,
        public int $user_id,
        public int $product_id,
        public string $amount,
        public string $status,
        public string $processed_at,
        public string $created_at,
        public string $updated_at,
    ) {}
}

This will map the data from the API fine, but it can be a lot better. The first thing that jumps out to me is the amount comes in as a string. In my application, I typically prefer to deal with monetary values as cents using integers. However, maybe I have another external service that is expecting monetary values to be passed as a float. This is a great case for using a value object so I am not constantly doing these conversions all over the application.

Here’s a simple example of what my Currency value object might look:

class Currency
{
    public readonly string $display;
    public readonly int $cents;
    public readonly float $dollars;

    public function __construct(
        public readonly mixed $value,
    )
    {
        match (true) {
            is_int($value) => $this->cents = $value,
            is_float($value) => $this->cents = $this->floatToCents($value),
            is_string($value) => $this->cents = $this->stringToCents($value),
            default => throw new InvalidArgumentException('Invalid value for Currency'),
        };

        $this->dollars = $this->cents / 100;
        $this->display = number_format($this->dollars, 2);
    }

    private function floatToCents(float $value): int
    {
        return (int) (round($value, 2) * 100);
    }

    private function stringToCents(string $value): int
    {
        return $this->floatToCents((float) $value);
    }
}

The Currency class can accept an integer, string, or float value, and convert as needed into a cents integer. However, it also gives me the option to get a dollar float value or even a display string. It also has some built-in validation to make sure any other value that might be passed into this class will throw an exception. This is just a simple example and in a normal application, you might also be tracking the type of currency or need some additional validation, but this will work for my purposes right now.

So now, I can update my OrderData class to the following:

class OrderData extends Data
{
    public function __construct(
        public int $id,
        public int $user_id,
        public int $product_id,
        public Currency $amount,
        public string $status,
        public string $processed_at,
        public string $created_at,
        public string $updated_at,
    ) {}
}

Now the $amount is a Currency type. However, Laravel Data does not know how to instantiate this object. This is where a cast comes into play. A cast in Laravel Data is used to convert simple API data into a complex object. To create this in Laravel Data, I need a class that implements the Spatie\LaravelData\Casts\Cast interface, which looks like the following:

interface Cast
{
    public function cast(DataProperty $property, mixed $value, array $context): mixed;
}

The $property parameter is an object that represents the property on the Laravel Data object and stores various information about the property. You can read more here. The $value parameter is the value that is being passed into the Laravel data object for the property, in my case, this will be the money string "10.99". Finally, the $context array is an array of the rest of the data being passed into the data object.

A cast implementation for my Currency object looks like the following:

class CurrencyCast implements Cast
{
    public function cast(DataProperty $property, mixed $value, array $context): Currency
    {
        return new Currency($value);
    }
}

Pretty simple right? I just need to return a new Currency object by passing the $value to it. To make this work with my data object, I can use a property attribute:

class OrderData extends Data
{
    public function __construct(
        public int $id,
        public int $user_id,
        public int $product_id,
        #[WithCast(CurrencyCast::class)]
        public Currency $amount,
        public string $status,
        public string $processed_at,
        public string $created_at,
        public string $updated_at,
    ) {}
}

Now, any time my OrderData object is created, instead of just having a string value for $amount, I now have a much more helpful Currency object.

This can still be improved though! This data object has three different date strings and I’d prefer to use those as a Carbon object in Laravel. You can think of a Carbon date as a value object and I want to cast my various dates to that. The good news, this comes out of the box in Laravel Data, all I need to do is update the types in my object.

class OrderData extends Data
{
    public function __construct(
        public int $id,
        public int $user_id,
        public int $product_id,
        #[WithCast(CurrencyCast::class)]
        public Currency $amount,
        public string $status,
        public Carbon $processed_at,
        public Carbon $created_at,
        public Carbon $updated_at,
    ) {}
}

Now, if I create a new data object, I have Carbon instances instead of strings.

$data = OrderData::from([
    'id' => 123,
    'user_id' => 345,
    'product_id' => 678,
    'amount' => "10.99",
    'status' => "success",
    'processed_at' => '2023-09-30T10:00:00+00:00',
    'created_at' => '2023-09-28T10:00:00+00:00',
    'updated_at' => '2023-09-30T10:00:00+00:00',
]);

$data->processed_at::class;
// "Carbon\Carbon"

You might be wondering how this works since I didn’t use a cast anywhere. As I mentioned, this is built-in with Laravel Data and it is handled in the configuration file using a global cast.

// /app/config/data.php

return [
    ...
    /*
     * Global casts will cast values into complex types when creating a data
     * object from simple types.
     */
    'casts' => [
        DateTimeInterface::class => Spatie\LaravelData\Casts\DateTimeInterfaceCast::class,
        BackedEnum::class => Spatie\LaravelData\Casts\EnumCast::class,
    ],
    ...
];

When Laravel Data runs across a complex type, it will first check if a Cast has been configured in the object definition, and if not, it will attempt to fall back to the global casts. For Carbon, this is the DateTimeInterfaceCast. If Laravel Data sees a property that has a type that implements the DateTimeInterface, which Carbon does, it will attempt to cast the value of that property to the type specified.

Now, imagine I have many other data transfer objects that might contain monetary values, which could be integers, strings, or floats. Instead of explicitly adding the cast attribute in each data transfer object, it can instead be added to the global casts array.

return [
  ...
  /*
   * Global casts will cast values into complex types when creating a data
   * object from simple types.
   */
  'casts' => [
      DateTimeInterface::class => Spatie\LaravelData\Casts\DateTimeInterfaceCast::class,
      BackedEnum::class => Spatie\LaravelData\Casts\EnumCast::class,
      \App\ValueObjects\Currency::class => \App\Data\Casts\CurrencyCast::class,
  ],
  ...
];

With the global cast set, the OrderData object no longer needs the cast attribute:

class OrderData extends Data
{
    public function __construct(
        public int $id,
        public int $user_id,
        public int $product_id,
        public Currency $amount,
        public string $status,
        public Carbon $processed_at,
        public Carbon $created_at,
        public Carbon $updated_at,
    ) {}
}

Though not necessarily a value object, the $status can also be improved here. Let’s say status can be one of three values, “pending”, “success”, or “failed”. This is a perfect case for an enum in PHP which could look like the following:

enum OrderStatus: string
{
    case PENDING = 'pending';
    case SUCCESS = 'success';
    case FAILED = 'failed';
}

To handle this in the Laravel Data object, I just need to update the type:

class OrderData extends Data
{
    public function __construct(
        public int $id,
        public int $user_id,
        public int $product_id,
        public Currency $amount,
        public OrderStatus $status,
        public Carbon $processed_at,
        public Carbon $created_at,
        public Carbon $updated_at,
    ) {}
}

Similar to the Carbon casts, Laravel Data has built-in support for casting to enums using Spatie\LaravelData\Casts\EnumCast::class.

I’ve covered using casts in Laravel Data, now I will move on to transformers. A transformer is essentially the opposite of a cast. A transformer takes a complex object and converts it to simple values to pass to JSON.

In my OrderData example, if I wanted to pass the data to another API, I probably don’t want to pass Currency or Carbon objects. When I convert my OrderData instance to JSON, I get something like the following:

{
    "id": 123,
    "user_id": 345,
    "product_id": 678,
    "amount": {
        "display": "$10.99",
        "cents": 1099,
        "dollars": 10.99,
        "value": "10.99"
    },
    "status": "success",
    "processed_at": "2023-09-30T10:00:00+00:00",
    "created_at": "2023-09-28T10:00:00+00:00",
    "updated_at": "2023-09-30T10:00:00+00:00"
}

Some good news and bad news. Like the built-in casts, Laravel Data has built-in transformers for BackedEnum and DateTimeInterface objects, so my $status field and various date fields have been converted to strings. However, my $amount field is incompatible with the API I am calling. I need that data back into a string, so I need a custom transformer class.

To create the transformer, I need to use the Spatie\LaravelData\Transformers\Transformer interface:

interface Transformer
{
    public function transform(DataProperty $property, mixed $value): mixed;
}

So, for my Currency object, a transformer could look like the following:

class CurrencyTransformer implements Transformer
{
    public function transform(DataProperty $property, mixed $value): string
    {
        return $value->display;
    }
}

With that in place, I can add an attribute to my OrderData class.

class OrderData extends Data
{
    public function __construct(
        public int $id,
        public int $user_id,
        public int $product_id,
        #[WithTransformer(CurrencyTransformer::class)]
        public Currency $amount,
        public OrderStatus $status,
        public Carbon $processed_at,
        public Carbon $created_at,
        public Carbon $updated_at,
    ) {}
}

Now, when converting to JSON, my output looks like the following:

{
    "id": 123,
    "user_id": 345,
    "product_id": 678,
    "amount": "10.99",
    "status": "success",
    "processed_at": "2023-09-30T10:00:00+00:00",
    "created_at": "2023-09-28T10:00:00+00:00",
    "updated_at": "2023-09-30T10:00:00+00:00"
}

Just like global casts, global transformers can be configured as well.

I hope this article for learning how to use casts and transformers in Laravel Data to work with value objects. Refer to the documentation for more information:

Laravel Data is an extremely useful package and is very flexible to support whatever needs may arise. To learn more, I recommend looking into pipelines for Laravel Data as a next step.

Thanks for reading!

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