Fat Models, Skinny Controllers
A controller translates an HTTP request into a response — nothing more. Here is why query and business logic belongs on the model, and where the rule stops applying.

Laravel gives you a lot of freedom in where you put your code. That freedom is exactly why so many Laravel codebases end up with 400-line controller methods that nobody wants to touch. The single cheapest habit you can adopt to avoid that is the oldest one in the book: fat models, skinny controllers.
What a controller is actually for
A controller has one job: translate an HTTP request into a response. Receive input, hand it to something that knows the domain, return a view or a redirect. Everything else — query construction, business rules, formatting — belongs somewhere else.
Here is the kind of code that shows up when that boundary is ignored:
public function index()
{
$clients = Client::verified()
->with(['orders' => function ($query) {
$query->where('created_at', '>', now()->subDays(7));
}])
->get();
return view('index', compact('clients'));
}
It works. It is also the wrong place for it. That query encodes two business rules — what "verified" means and what "recent" means — inside an HTTP handler. The moment a second controller, an Artisan command, or a queued job needs the same clients, the query gets copy-pasted, and the two copies start drifting apart.
Push the knowledge down into the model
Move the query into the model that owns the data, and express the rules as scopes:
public function index(Client $client)
{
return view('index', ['clients' => $client->getVerifiedWithRecentOrders()]);
}
class Client extends Model
{
public function getVerifiedWithRecentOrders(): Collection
{
return $this->verified()
->with(['orders' => fn ($query) => $query->recent()])
->get();
}
public function scopeVerified($query)
{
return $query->where('is_verified', true);
}
}
class Order extends Model
{
public function scopeRecent($query)
{
return $query->where('created_at', '>', now()->subDays(7));
}
}
The controller is now one line, and it reads like a sentence. More importantly, recent() lives on
Order, where it belongs. When the definition of "recent" changes from seven days to thirty, you
change it in exactly one place and every caller — controllers, jobs, commands, reports — follows.
Why this pays off
Reuse. A scope is available everywhere the model is. A query buried in a controller is available in exactly one action.
Readable diffs. Client::verified()->with('orders') tells a reviewer what you meant.
->where('is_verified', 1) tells them what you typed.
Testability. Scopes are trivial to unit test without touching the HTTP layer. Controllers that delegate need only a thin feature test proving the wiring works.
One place to optimise. When a query turns out to be slow, or needs an index, or needs eager loading to kill an N+1, there is a single definition to fix.
Where "fat" stops being a virtue
Taken too far, this advice produces 2,000-line models that are just as unmaintainable as the controllers they replaced. The model is the right home for data access and data-shaped rules: scopes, relationships, accessors, casts. It is not the right home for orchestration — sending mail, charging a card, talking to a third-party API, coordinating several models inside a transaction. That work belongs in a dedicated service or action class, which the controller calls instead.
A useful rule of thumb:
- Query or attribute logic about one model → the model (scope, accessor, relationship).
- A workflow spanning several models or external systems → a service or action class.
- Turning a request into a response → the controller, and nothing more.
The practical test
Open any controller in your project and read one method. If you can explain what it does without reading the query builder chain inside it, the boundary is in the right place. If you have to trace three levels of closures to work out which records come back, that logic wants to live on the model.
Skinny controllers are not a style preference. They are what keeps business rules findable six months from now, when the person opening the file is you and you have forgotten every decision you made today.