Laravel Data and Value Objects
Sean Kegel
Posted on September 30, 2023
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:
- Value Objects Everywhere — Martin Joo
- Is it a DTO or a Value Object — Matthias Noback
- Building Resilient Code: Harnessing the Power of Value Objects
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 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);
}
}
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,
) {}
}
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;
}
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 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,
) {}
}
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. 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,
],
...
];
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,
],
...
];
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 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!
Posted on September 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.