An eloquent way of handling model state
Almost every application has some sort of state to it. It’s why we have databases.
However, often times you’ll find that your models, at any given point, has a certain status to them too. A few examples could be:
- Post: draft, private, published,
- Job: applied, accepted, declined, completed, cancelled
- Approval: pending, reviewing, approved
In this blog post we’ll review some of the traditional approaches, discuss pro’s and con’s, and finally uncover how a few packages by the end will allow us to write zonda code 🔥
TLDR
With makeabledk/laravel-querykit you can reuse your eloquent query-scopes and check if a model passes a given scope: $approval->passesScope('pending')
Furthermore using makeabledk/laravel-eloquent-status allows you to put all statuses into its own class meanwhile achieve a sweet syntax like Approval::status('pending')->get()
and $approval->checkStatus('pending')
Read on!
Our example
Let’s use the Approval example from before and setup some ground rules.
Business case
1. In a classroom environment, students are handing in assignments for approval..
An approval is given by three different actors to be complete:
a tutor, teacher and external assessor.2. They will always give their approval in that order.
3. The different stages the approval undergoes are pending, reviewing and approved.
Let’s review a few traditional ways we may accomplish this behavior..
Traditional approach 1:
Manipulate a
status
field in your database
Keeping it totally simple, we could just have a status
field in the database that we change to reflect the current status.
It would look something along the lines of this:
- New approval:
status
→pending
- Tutor approves:
status
→reviewing
- Teacher approves:
teacher_approved
→1
Wait! what? Yes, we need to track that the teacher approved, so we’ll know that for when the assessor approves. But we aren’t ready to change the status just yet… - Assessor approves:
status
→approved
As we can see it get’s a little hairy because not every action results in a status change.
Realistically you’ll therefore end up with separate database fields for every action. Also, instead of using booleans let’s do timestamps for better history preservation.
Now our table looks something like this:
Pros
- Easy querying of status
- Easy manipulation of status — just change it at the right time
Cons
- Not one source of truth. The
status
is not derived from the timestamps but rather just being manipulated at different triggers - No trail as to how the
status
became what it is. Especially with changing domain logic this becomes a big pitfall - Responsibility of changing the
status
possibly leaks out in the application
Traditional approach 2:
Determine status by querying your database
Continuing from the previous setup, let’s drop the status
field in the database and just query on the fly.
Luckily this is a cinch in Laravel!
With the above scope function we can easily query all approvals and just fetch the pending ones with: Approval::pending()->get() // Collection
But what if we needed to check if a given approval was in fact approved
before performing some action? Ouch 🤕
Have your models ever bloated up like that too?
Pros
- Keeps data-integrity
- Statuses can be altered without migrations as business changes
- Clear definitions of each status that matches the business rules
Cons
- Often times you’ll find yourself duplicating logic to query the database & single instances respectively. This makes it error-prone in case of changes.
- Your models easily become bloated with query-scopes and helper-functions.
The enhanced eloquent approach
Reuse your scope functions when checking your models
Huh, come again?
Frustrated by the code-duplication from previous example, at makeable.dk we decided to come up with a solution for it.
That solution is called makeabledk/laravel-querykit and it allows you to check if a model instance adheres to a scope function.
Simply install the package and add the QueryKit
trait to your model, and you are good to go!
$ composer require makeabledk/laravel-querykit
As usual you can query against your database with eloquent:
Approval::pending()->get() // Collection
But now you can also check if a single instance passes your scope:
$approval->passesScope('pending'); // true or false
Under the hood this is achieved by passing the scope a custom query builder that exposes the same interface as the native laravel builder.
However, instead of searching your database it compares against your model-attributes.
Pros (same as previous)
- Keeps data-integrity
- Statuses can be altered without migrations as business changes
- Clear definitions of each status that matches the business rules
Cons
- O̵f̵t̵e̵n̵ ̵t̵i̵m̵e̵s̵ ̵y̵o̵u̵’̵l̵l̵ ̵f̵i̵n̵d̵ ̵y̵o̵u̵r̵s̵e̵l̵f̵ ̵d̵u̵p̵l̵i̵c̵a̵t̵i̵n̵g̵ ̵l̵o̵g̵i̵c̵ ̵t̵o̵ ̵q̵u̵e̵r̵y̵ ̵t̵h̵e̵ ̵d̵a̵t̵a̵b̵a̵s̵e̵ ̵&̵ ̵s̵i̵n̵g̵l̵e̵ ̵i̵n̵s̵t̵a̵n̵c̵e̵s̵ ̵r̵e̵s̵p̵e̵c̵t̵i̵v̵e̵l̵y̵.̵ ̵T̵h̵i̵s̵ ̵m̵a̵k̵e̵s̵ ̵i̵t̵ ̵e̵r̵r̵o̵r̵-̵p̵r̵o̵n̵e̵ ̵i̵n̵ ̵c̵a̵s̵e̵ ̵o̵f̵ ̵c̵h̵a̵n̵g̵e̵s̵.̵
Fixed it! - Your models easily become bloated with query-scopes and helper-functions.
We’re almost there!
🔥 Introducing status classes
Consider this:
- Instead of putting everything in the model, we make a dedicated
ApprovalStatus
that holds the query scopes. Business definitions are encapsulated in one place. - When querying
Approval
we pass theApprovalStatus
object
This is exactly what makeabledk/laravel-eloquent-status allows you to do.
Let’s look some code.
You may query your database with:Approval::status(new ApprovalStatus('reviewing'))->get() // Collection
And check your model with:$approval->checkStatus(new ApprovalStatus('reviewing')) // boolean
This is pretty good already. But since it can get a little verbose with the new ApprovalStatus()
all the time, we can sugar it up a bit more!
In your AppServiceProvider
‘s boot
method you may bind a default status class a model with:StatusManager::bind(Approval::class, ApprovalStatus::class);
Now you may simply do$pendingApproval->checkStatus('pending') // true
🔥🔥
Pros
- Keeps data-integrity
- Statuses can be altered without migrations as business changes
- Clear definitions of each status that matches the business rules
- All definitions together in one place. You can even get available statuses with
ApprovalStatus::all() // ['pending', 'reviewing', 'approved']
- No
enum
‘s and no typos. You can only pass valid statuses or an exception will be thrown.
For this reason you may even pass raw$_GET
in your controller methodreturn Approval::status($_GET['filter'])->get()
- Uniformed way of defining model-statuses all across your application!
- No more if-switches!
Cons
- Not all query-builder methods are available in QueryKit, especially raw SQL is a no-go.
Nevertheless, in by far most use-cases you will be able to determine model-status from its own attributes.
In the rare cases it’s not, sometimes it can be a hint to review your database structure or leverage techniques such as denormalization.