DEV Community

Cover image for Laravel Data and Value Objects
Sean Kegel
Sean Kegel

Posted on • Originally published at seankegel.com on

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’s 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",
}
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

This will map the data from the API fine, but it can be a lot better. The first things 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 dollars 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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Now the $amount is a Currency type. However, Laravel Data does not know how to actually 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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 really prefer to use those as a Carbon object in Laravel. You can actually 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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

You might be wondering how this works since I didn’t use a cast anywhere. Like 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,
    ],
    ...
];
Enter fullscreen mode Exit fullscreen mode

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 fallback 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,
  ],
  ...
];
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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

I hope this article was helpful in 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!

Top comments (0)