Skip to main content

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.

✅ Good: defining a relationship
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.

✅ Good: expressing the total as 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.

✅ Good: casting the status to an enum
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.

✅ Good: a local scope
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.

❌ Bad: a method with several concerns (repeated 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?"

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
  1. An accessor that calculates and returns the total item count from the order's items relationship
  2. Processing that determines whether an order is confirmed by cross-checking against another order history table
  3. A setting that casts the order status to an enum

Sample answer

  1. Belongs: it only interprets data from a relationship the model holds, so it is the model's own responsibility
  2. Does not belong: cross-checking against another table is a decision beyond interpreting the Order's own data
  3. Belongs: how the type of a stored value is handled is the model's own responsibility

Footnotes​

  1. Source: Laravel documentation — Eloquent: Getting Started. It explains that the design is based on the Active Record pattern, interacting with the database through a "model" that corresponds to each database table. ↩

  2. Source: Laravel documentation — Eloquent: Mutators & Casting. Defining accessors with Attribute::make() is the currently recommended pattern. ↩

  3. Source: Laravel 11 release notes. Defining casts with the casts() method is the form introduced in Laravel 11. ↩

  4. Source: Laravel documentation — Eloquent: Getting Started. Local scopes are defined with methods prefixed by scope. ↩