25
Using value-objects in Laravel Models
(image credits the Joomla Community)
A value object is a small object that represents a simple entity whose equality is not based on identity: i.e. two value objects are equal when they have the same value, not necessarily being the same object.
Examples of value objects are objects representing an amount of money or a date range.
Value objects should be immutable: this is required for the implicit contract that two value objects created equal, should remain equal. It is also useful for value objects to be immutable, as client code cannot put the value object in an invalid state or introduce buggy behaviour after instantiation.
You may read the full definition on Wikipedia.
In short: Value Objects are objects that
- Hold a value
- Are (generally) immutables
- Carry more context than native types
Consider the following tables:
┌───────────────────┐ ┌────────────────────┐
│ invoices │ │ invoice_line_items │
├───────────────────┤1,1 ├────────────────────┤
│ id (int, primary) │◄──┐ │ id (int, primary) │
│ customer_id (int) │ └───┤ invoice_id (int) │
│ status (string) │ 0,N│ label (string) │
└───────────────────┘ │ quantity (int) │
│ unit_price (int) │
└────────────────────┘
(made with asciiflow)
We can immediately identify the following rules that apply here:
-
invoices.status
values are constrained within a list of possible values (Sent, Paid, Void, etc.) -
invoice_line_items.quantity
andinvoice_line_items.unit_price
cannot be negative
While it could make sense to host the code responsible for checking the data integrity inside the models - in Invoice::setStatusAttribute
and InvoiceLineItem::setQuantityAttribute
for instance - I'm going to present you a more robust and elegant way to implement those rules.
Let's start with App\Models\InvoiceStatus
(we're going to host the value objects in the same namespace as the models, more on that later.)
namespace App\Models;
final class InvoiceStatus
{
private function __construct(
private string $value
) {}
public function __toString()
{
return $this->value;
}
public function equals(self $status): bool
{
return $this->value == $status->value;
}
public static function fromString(string $status): self
{
return match ($status) {
'draft' => self::draft(),
'sent' => self::sent(),
'paid' => self::paid(),
'overdue' => self::overdue(),
'void' => self::void(),
'writeOff' => self::writeOff(),
default: throw new \InvalidArgumentException("Invalid status '{$status}'");
};
}
public static function draft(): self
{
/* You’ve created an incomplete invoice and it hasn’t been sent to the customer. */
return new self('draft');
}
public static function sent(): self
{
/* Invoice has been sent to the customer. */
return new self('sent');
}
public static function paid(): self
{
/* Invoice has been paid by the customer. */
return new self('paid');
}
public static function overdue(): self
{
/* Invoice has past the payment date and the customer hasn't paid yet. */
return new self('overdue');
}
public static function void(): self
{
/* You will void an invoice if it has been raised incorrectly. Customers cannot pay for a voided invoice. */
return new self('void');
}
public static function writeOff(): self
{
/* You can Write Off an invoice only when you're sure that the amount the customer owes is uncollectible. */
return new self('write-off');
}
}
You may have noticed the constructor is private, and the class is final. This ensures that nobody can derive it to create new values or instances with an invalid state, constraining the possible values to strictly the list provided by the static methods.
Trust me; this will help A LOT down the road because the values can only be changed at a single place in the app. No more search for incorrect constants & values with CTRL+SHIFT+F!
Turns out Laravel has a native solution to perform value object casting in models! All we need to do is to create a new custom cast with:
php artisan make:cast InvoiceStatusCast
And then fill it with:
namespace App\Casts;
use App\Models\InvoiceStatus;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class InvoicestatusCast extends CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
if (is_null($value)) {
return null;
}
return InvoiceStatus::fromString($value);
}
public function set($model, $key, $value, $attributes)
{
if (is_string($value)) {
$value = InvoiceStatus::fromString($value);
}
if (! $value instanceof InvoiceStatus) {
throw new \InvalidArgumentException(
"The given value is not an InvoiceStatus instance",
);
}
return $value;
}
}
Now we've defined our value object and our attribute caster, let's make Invoice
use it.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Casts\InvoiceStatusCast;
class Invoice extends Model
{
protected $fillable = [
'status',
];
protected $attributes = [
/* default attributes values */
'status' => InvoiceStatus::draft(),
];
protected $casts = [
'status' => InvoiceStatusCast::class,
];
public function lineItems()
{
return $this->hasMany(InvoiceLineItem::class);
}
}
Now let's quickly do the two others:
namespace App\Models;
class UnsignedInteger
{
private $value;
public function __construct(int $value)
{
if ($value <= 0) {
throw new \UnexpectedValueException(static::class . " value cannot be lower than 1");
}
$this->value = $value;
}
public function value(): int
{
return $this->value;
}
}
final class InvoiceLineItemQuantity extends UnsignedInteger
{
public function add(self $quantity): self
{
return new self($this->value() + $quantity->value());
}
public function substract(self $quantity): self
{
return new self($this->value() - $quantity->value());
}
}
final class InvoiceLineItemUnitPrice extends UnsignedInteger
{
public function increase(self $price): self
{
return new self($this->value() + $price->value());
}
public function decrease(self $price): self
{
return new self($this->value() - $price->value());
}
}
You might be tempted to move InvoiceLineItemQuantity::add
and InvoiceLineItemUnitPrice::increase
to UnsignedInteger
for instance, and maybe rename them both to add
or sum
, but then you would make it possible to write $price->add($quantity)
which is a bit silly.
Those methods * look like duplication* but it's accidental. You need to keep them separated in their classes so you can precisely match the ubiquitous language of your business domain.
Use exactly the same logic as above to write the cast classes and then add them to the model:
namespace App\Models;
class InvoiceLineItem extends Model
{
protected $fillable = [
'label',
'quantity',
'unit_price',
];
protected $casts = [
'quantity' => InvoiceLineItemQuantityCast::class,
'unit_price' => InvoiceLineItemUnitPriceCast::class,
];
public function invoice()
{
return $this->belongsTo(Invoice::class)->withDefault();
}
}
Now we have made a value object that guarantees data integrity by design. From your model point of view, this means it is impossible to write something in the database that doesn't match your business logic.
$invoice = tap($customer->invoices()->create(), function ($invoice) {
$invoice->lineItems()->create([
'label' => "Dog food"
'quantity' => new InvoiceLineItemQuantity(3);
'unit_price' => new InvoiceLineItemUnitPrice(314); // $3.14
]);
$invoice->lineItems()->create([
'label' => "Cat food"
'quantity' => new InvoiceLineItemQuantity(5);
'unit_price' => new InvoiceLineItemUnitPrice(229); // $2.29
]);
});
Mail::to($customer->email)->send(new InvoiceAvailable($invoice));
$invoice->update([
'status' => InvoiceStatus::sent()
]);
The code responsible for handling validation has to go somewhere. In my opinion, it's far better to manipulate values that validates themselves rather than having validation code laying around across the whole app. Also, value objects add a lot more context to those values: you need to look at them to understand what they represent and their underlying rules. They are the embodiment of actual business constraints within your code.
I choose to locate my value objects alongside the eloquent models in my apps. I do this for two reasons: I have nowhere else to put them, and, more importantly, it is not rare a value object becomes an actual model. For example, what if my customers want to create custom statuses like "Needs validation by Mike, the accountant".
Well, you would then create an invoice_statuses
table, right? Then create an InvoiceStatus model object... Wait! You can reuse the existing InvoiceStatus class and implement its methods to use the database instead of static values. This way, you keep the current code intact. This is HUGE!
I highly recommend you take a look at the excellent spatie/enum which does the heavy lifting for you. There is also to the new PHP8.1 Enum structure which will make all the above code even easier to write!
As usual, don't forget to like, leave a comment, and follow me on dev.to. It helps me stay motivated to write more articles for you!
25