Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Using value-objects in Laravel Models
Benjamin Delespierre
Benjamin Delespierre

Posted on • Edited on

     

Using value-objects in Laravel Models

(image creditsthe Joomla Community)

Value Objects

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 onWikipedia.

In short:Value Objects are objects that

  • Hold a value
  • Are (generally) immutables
  • Carry more context than native types

Use Case

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)   │                            └────────────────────┘
Enter fullscreen modeExit fullscreen mode

(made withasciiflow)

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 itcould make sense to host the code responsible for checking the data integrity inside the models - inInvoice::setStatusAttribute andInvoiceLineItem::setQuantityAttribute for instance - I'm going to present you amore robust and elegant way to implement those rules.

Introducing Value Objects

Let's start withApp\Models\InvoiceStatus (we're going to host the value objects in the same namespace as the models, more on that later.)

namespaceApp\Models;finalclassInvoiceStatus{privatefunction__construct(privatestring$value){}publicfunction__toString(){return$this->value;}publicfunctionequals(self$status):bool{return$this->value==$status->value;}publicstaticfunctionfromString(string$status):self{returnmatch($status){'draft'=>self::draft(),'sent'=>self::sent(),'paid'=>self::paid(),'overdue'=>self::overdue(),'void'=>self::void(),'writeOff'=>self::writeOff(),default:thrownew\InvalidArgumentException("Invalid status '{$status}'");};}publicstaticfunctiondraft():self{/* You’ve created an incomplete invoice and it hasn’t been sent to the customer. */returnnewself('draft');}publicstaticfunctionsent():self{/* Invoice has been sent to the customer. */returnnewself('sent');}publicstaticfunctionpaid():self{/* Invoice has been paid by the customer. */returnnewself('paid');}publicstaticfunctionoverdue():self{/* Invoice has past the payment date and the customer hasn't paid yet. */returnnewself('overdue');}publicstaticfunctionvoid():self{/* You will void an invoice if it has been raised incorrectly. Customers cannot pay for a voided invoice. */returnnewself('void');}publicstaticfunctionwriteOff():self{/* You can Write Off an invoice only when you're sure that the amount the customer owes is uncollectible. */returnnewself('write-off');}}
Enter fullscreen modeExit fullscreen mode

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 helpA LOT down the road becausethe values can only be changed at a single place in the app. No more search for incorrect constants & values with CTRL+SHIFT+F!

Value Object Casting

Turns out Laravel has a native solution to performvalue object casting in models! All we need to do is to create a new custom cast with:

php artisan make:cast InvoiceStatusCast
Enter fullscreen modeExit fullscreen mode

And then fill it with:

namespaceApp\Casts;useApp\Models\InvoiceStatus;useIlluminate\Contracts\Database\Eloquent\CastsAttributes;classInvoicestatusCastextendsCastsAttributes{publicfunctionget($model,$key,$value,$attributes){if(is_null($value)){returnnull;}returnInvoiceStatus::fromString($value);}publicfunctionset($model,$key,$value,$attributes){if(is_string($value)){$value=InvoiceStatus::fromString($value);}if(!$valueinstanceofInvoiceStatus){thrownew\InvalidArgumentException("The given value is not an InvoiceStatus instance",);}return$value;}}
Enter fullscreen modeExit fullscreen mode

The Model

Now we've defined our value object and our attribute caster, let's makeInvoice use it.

namespaceApp\Models;useIlluminate\Database\Eloquent\Model;useApp\Casts\InvoiceStatusCast;classInvoiceextendsModel{protected$fillable=['status',];protected$attributes=[/* default attributes values */'status'=>InvoiceStatus::draft(),];protected$casts=['status'=>InvoiceStatusCast::class,];publicfunctionlineItems(){return$this->hasMany(InvoiceLineItem::class);}}
Enter fullscreen modeExit fullscreen mode

More Value Objects

Now let's quickly do the two others:

namespaceApp\Models;classUnsignedInteger{private$value;publicfunction__construct(int$value){if($value<=0){thrownew\UnexpectedValueException(static::class." value cannot be lower than 1");}$this->value=$value;}publicfunctionvalue():int{return$this->value;}}finalclassInvoiceLineItemQuantityextendsUnsignedInteger{publicfunctionadd(self$quantity):self{returnnewself($this->value()+$quantity->value());}publicfunctionsubstract(self$quantity):self{returnnewself($this->value()-$quantity->value());}}finalclassInvoiceLineItemUnitPriceextendsUnsignedInteger{publicfunctionincrease(self$price):self{returnnewself($this->value()+$price->value());}publicfunctiondecrease(self$price):self{returnnewself($this->value()-$price->value());}}
Enter fullscreen modeExit fullscreen mode

You might be tempted to moveInvoiceLineItemQuantity::add andInvoiceLineItemUnitPrice::increase toUnsignedInteger for instance, and maybe rename them both toadd orsum, 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 canprecisely match theubiquitous language of yourbusiness domain.

Use exactly the same logic as above to write the cast classes and then add them to the model:

namespaceApp\Models;classInvoiceLineItemextendsModel{protected$fillable=['label','quantity','unit_price',];protected$casts=['quantity'=>InvoiceLineItemQuantityCast::class,'unit_price'=>InvoiceLineItemUnitPriceCast::class,];publicfunctioninvoice(){return$this->belongsTo(Invoice::class)->withDefault();}}
Enter fullscreen modeExit fullscreen mode

Usage

Now we have made a value object that guarantees data integrityby 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'=>newInvoiceLineItemQuantity(3);'unit_price'=>newInvoiceLineItemUnitPrice(314);// $3.14]);$invoice->lineItems()->create(['label'=>"Cat food"'quantity'=>newInvoiceLineItemQuantity(5);'unit_price'=>newInvoiceLineItemUnitPrice(229);// $2.29]);});Mail::to($customer->email)->send(newInvoiceAvailable($invoice));$invoice->update(['status'=>InvoiceStatus::sent()]);
Enter fullscreen modeExit fullscreen mode

Isn't it a little too complex?

The code responsible for handling validation has to go somewhere. In my opinion, it's far better to manipulate values thatvalidates 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 aninvoice_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, youkeep the current code intact. This isHUGE!

Going further

I highly recommend you take a look at theexcellent spatie/enum which does the heavy lifting for you. There is also to thenew 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 ondev.to. It helps me stay motivated to write more articles for you!

Top comments(11)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
systemovich profile image
Geoffrey Van Wyk
  • Joined

If$value is private, then this comparison:

publicfunctionequals(self$status):bool{return$this->value==$status->value;}
Enter fullscreen modeExit fullscreen mode

should not be possible?

CollapseExpand
 
bdelespierre profile image
Benjamin Delespierre
I do all sorts of mischiefs with Laravel
  • Location
    Paris, France
  • Joined

In PHP encapsulation is done at class level, not object level, so you can "visit" other instances' private properties value.

CollapseExpand
 
systemovich profile image
Geoffrey Van Wyk
  • Joined

I stand corrected.

Objects of the same type will have access to each others private and protected members even though they are not the same instances.

CollapseExpand
 
systemovich profile image
Geoffrey Van Wyk
  • Joined

PHP needs a__equals magic method :)

CollapseExpand
 
bdelespierre profile image
Benjamin Delespierre
I do all sorts of mischiefs with Laravel
  • Location
    Paris, France
  • Joined

That would be fantastic. How about you draft an RFC for that?

CollapseExpand
 
systemovich profile image
Geoffrey Van Wyk
  • Joined
• Edited on• Edited

Someone proposed it for PHP 7.3, but it was declined:PHP RFC: User-defined object comparison

It is difficult to find the reasons for why it was declined:github.com/php/php-src/pull/3339#i...

As someone mentioned on a PHP podcast, the PHP RFC voting system needs a lot of improvement.

Thread Thread
 
bdelespierre profile image
Benjamin Delespierre
I do all sorts of mischiefs with Laravel
  • Location
    Paris, France
  • Joined

I understand. While things could certainly be better, I think we need to be careful: changing the RFC system could jeopardize the backward-compatibility, which is IMHO the best aspect of the platform.

CollapseExpand
 
mikejtz profile image
Michel JAUTZY
  • Joined

Great article, great usecase !

CollapseExpand
 
bdelespierre profile image
Benjamin Delespierre
I do all sorts of mischiefs with Laravel
  • Location
    Paris, France
  • Joined

Thanks 🙏

CollapseExpand
 
shifrin profile image
Mohammed Shifreen
  • Joined

Is it really necessary to make constructor private for final class?

CollapseExpand
 
jovialcore profile image
Chidiebere Chukwudi
Software Developer. php| laravel | cakephp | vuejs/Nuxt | wordpress | Bootstrap | Tailwind

NIce one

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

I do all sorts of mischiefs with Laravel
  • Location
    Paris, France
  • Joined

More fromBenjamin Delespierre

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp