Responsibilities of an Eloquent model — what belongs and what does not
Chapter 8 showed the Fat Controller, which packs business logic into a controller, and the Fat Model, which merely moves that processing into the model. The problem with the Fat Model is not that the Eloquent model mechanism is bad in itself. The problem was packing several concerns into a single method without using the relationship, accessor, and cast features Eloquent provides. This chapter separates the responsibilities an Eloquent model should carry from the ones it should not.
What an Eloquent model is really responsible for
An Eloquent model is an implementation of the Active Record pattern1. One model instance corresponds to one database row, and it is responsible for how the data in that row is represented and interpreted. Concretely, the following four things are the Eloquent model's own responsibility.
Defining relationships
The fact that an Order has many OrderItems is information the model itself should know.
class Order extends Model
{
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
Writing $order->items fetches the related OrderItems together.
This is knowledge about the Order's own data: which OrderItems this Order is tied to.
Accessors and casts
How a stored value is interpreted on the way out is also the model's own responsibility.
In the Part 1 example, the Order class calculated the order total itself in a method called Order::calculateTotal().
An Eloquent model can express the same idea with an accessor.
class Order extends Model
{
protected function total(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => $this->items
->sum(fn (OrderItem $item) => $item->price * $item->quantity),
);
}
}
Writing $order->total gets you the total calculated from the order lines2.
That calculation — how to interpret the line data it holds — is part of how the Order represents its data.
The same goes for how the type of a stored value is handled.
Chapter 2 defined a backed enum called OrderStatus to represent a value that can only take a fixed set of options.
To treat the order status as that enum, specify it in the casts() method.
class Order extends Model
{
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
}
Written this way, $order->status is an OrderStatus instance rather than a string like 'pending'3.
Which type a stored value is read and written as is a decision that belongs to how the Order represents its data.
Local scopes
Turning a frequently used search condition into something reusable is also part of the model's responsibility.
class Order extends Model
{
public function scopeForCustomer(Builder $query, string $email): void
{
$query->where('customer_email', $email);
}
}
You call it under the name with the scope prefix removed, as in Order::forCustomer($email)->get()4.
This too is knowledge about the Order's data: how to filter its own table.
What does not belong in the model
Now go back to the Fat Model example from chapter 8.
class Order extends Model
{
public static function placeOrder(array $items, string $customerEmail): self
{
$total = 0;
foreach ($items as $item) {
$total += $item['price'] * $item['quantity'];
}
foreach ($items as $item) {
$stock = DB::table('stocks')->where('name', $item['name'])->value('quantity');
if ($stock < $item['quantity']) {
throw new RuntimeException('Not enough stock');
}
}
$order = self::create([
'customer_email' => $customerEmail,
'total' => $total,
]);
Mail::raw("Thank you for your order. Your total is {$total} yen.", function ($message) use ($customerEmail) {
$message->to($customerEmail)->subject('Order confirmation');
});
return $order;
}
}
Broken down, placeOrder() does the following.
- Calculates the total
- Checks the stock table (
stocks) - Creates the order record
- Sends an email
Of these, calculating the total is processing that can be extracted into the accessor we saw above.
Checking stock and sending mail, on the other hand, have nothing to do with how an Order represents its data.
The stock check is a decision about stock, a separate concern; sending mail is the execution of notification, another separate concern.
Neither has anything to do with how the data in the Order's row is interpreted.
How to decide
You can decide whether processing belongs in an Eloquent model with this question.
Does this processing complete entirely within the representation and interpretation of the model's own data?
Relationships, accessors, casts, and local scopes all answer the question "how do I present the data in my own row?"
Checking stock, by contrast, is a query against other data in the stock table, and sending mail is a request to an external mail delivery mechanism.
Both go beyond interpreting the Order's own data and coordinate several concerns.
Where that "processing that coordinates several concerns" belongs is the service class, covered in chapter 10.
Summary
- An Eloquent model's responsibility is closed around how the data in the row it represents is represented and interpreted
- Relationships, accessors and mutators, casts, and local scopes are all knowledge about the model's own data, and belong in the model
- Processing that spans several concerns, such as checking stock or sending mail, goes beyond the model's responsibility
- The criterion is "does this processing complete entirely within the representation and interpretation of the model's own data?"
What to read next
The next chapter covers where to put processing that goes beyond the model's responsibility. We look at how to decide when to extract processing into a Service class as a controller grows.
Exercises
For each of the following, say whether it belongs in an Eloquent model, and why
- An accessor that calculates and returns the total item count from the order's
itemsrelationship - Processing that determines whether an order is confirmed by cross-checking against another order history table
- A setting that casts the order status to an
enum
Sample answer
- Belongs: it only interprets data from a relationship the model holds, so it is the model's own responsibility
- Does not belong: cross-checking against another table is a decision beyond interpreting the
Order's own data - Belongs: how the type of a stored value is handled is the model's own responsibility