38
loading...
This website collects cookies to deliver better user experience
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.
┌───────────────────┐ ┌────────────────────┐
│ 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) │
└────────────────────┘
invoices.status
values are constrained within a list of possible values (Sent, Paid, Void, etc.)invoice_line_items.quantity
and invoice_line_items.unit_price
cannot be negativeInvoice::setStatusAttribute
and InvoiceLineItem::setQuantityAttribute
for instance - I'm going to present you a more robust and elegant way to implement those rules.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');
}
}
php artisan make:cast InvoiceStatusCast
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;
}
}
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);
}
}
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());
}
}
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.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();
}
}
$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()
]);
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!