Appearance
Tier 2b · Chapter 2: Laravel Architecture Boundaries
PHP/Laravel Track · ~30 min read + exercise
A note on sources: The architectural principles here (layering, hexagonal architecture, dependency direction) come from Domain-Driven Design in PHP and the foundations; the Laravel-specific mechanics (service container, where things live in a Laravel app) come from Laravel's documentation and common practice, flagged where relevant.
Why this chapter exists
Laravel is a productive, batteries-included framework, and that productivity is a double-edged sword for architecture. It makes it trivially easy to build a feature by putting everything in the controller — validation, business logic, database queries, even external API calls, all in one fat method — and for a small app that's genuinely fine. But as the app grows, the everything-in-the-controller habit produces exactly the technically-partitioned, change-spreading, untestable structure the foundations warned against. This chapter is about giving a Laravel app the clean internal boundaries the foundations call for: separating the framework-facing layer (controllers) from the business logic (services/domain) from the data access (repositories), so the app stays reasoned-about and changeable as it grows.
The throughline: Laravel's conveniences pull toward fat controllers and framework-coupled logic; good architecture pushes the business logic into a framework-independent core, with the framework at the edges. The goal isn't to fight Laravel — its conveniences are real and worth using — but to keep the domain logic (the rules about how your business works) separate from the framework plumbing (HTTP, routing, Eloquent), so that the part of your system that matters most isn't tangled up with the part most likely to change. This is the foundations' domain-partitioning and boundary lessons, applied inside a Laravel app.
What this chapter covers
- The fat controller problem — why Laravel's defaults drift toward it and what it costs.
- Layering a Laravel app — controllers, services/actions, domain, and repositories, and what belongs in each.
- Dependency direction — keeping the domain independent of the framework (the hexagonal idea).
- The service container — Laravel's tool for wiring it together without coupling.
The fat controller problem
A Laravel controller method can do everything, and the framework makes doing everything in it the path of least resistance. Here's the shape that emerges, and it's worth seeing clearly because it's so common:
php
// The fat controller — everything in one place. Works, until it doesn't scale.
public function store(Request $request)
{
$validated = $request->validate([/* ... */]); // validation
$customer = Customer::find($validated['customer_id']); // data access
if ($customer->tickets()->where('status','open')->count() >= 5) { // business rule
return response()->json(['error' => 'Too many open tickets'], 422);
}
$ticket = Ticket::create([/* ... */]); // data access
Mail::to($customer)->send(new TicketCreated($ticket)); // side effect
AnalyticsService::track('ticket_created', $ticket->id); // another side effect
return new TicketResource($ticket); // response
}It works. For a small app it's even reasonable. But every concern is fused: HTTP handling, validation, the business rule about ticket limits, data access, side effects, and response shaping all live in one method coupled to the framework. The costs are the foundations' costs made concrete. It's untestable without the full framework — you can't test the "max 5 open tickets" rule without HTTP, a request, and a database, because the rule is welded to all of them (the testability-is-structural point from the TS track applies identically). It's not reusable — the same business logic invoked from a queue job or an Artisan command means copying it or calling the controller awkwardly. And it spreads change — the business rule lives in the HTTP layer, so a change to the rule is a change to a controller, and the rule can't be found by anyone looking for "where the ticket rules live" because there is no such place. The fat controller is the technically-partitioned anti-pattern from Foundations Chapter 2, one method at a time.
Layering a Laravel app
The fix is to give the application layers with clear responsibilities, so each concern lives in one place and the business logic is separable from the framework. A pragmatic Laravel layering, from the edge inward:
Controllers (and form requests) are the thin HTTP edge. A controller's job is to receive the request, hand off to the business layer, and shape the response — nothing more. Validation moves to a Form Request (Laravel's mechanism for request validation, the parse-at-the-boundary idea in Laravel form), so the controller receives already-valid input. A good controller method is a few lines: validate (via form request), call a service or action, return a resource.
Services or Actions hold the business logic — the use cases. This is where "create a ticket, enforcing the open-ticket limit, notifying the customer" lives, as a method on a CreateTicket action or a TicketService, independent of HTTP. Laravel projects commonly use either service classes (grouping related use cases) or single-action classes (one class per use case — CreateTicketAction); both serve the same architectural purpose of getting the logic out of the controller into a callable, testable, reusable unit. (Flagged: the service-vs-action choice is a Laravel-community convention, not from the book set; either works.)
Domain holds the core concepts and rules — the entities and value objects from the previous chapter, and the rules that are intrinsic to the domain rather than to a use case. In a DDD-lite Laravel app (the next chapter goes deeper), this is where a Ticket is more than an Eloquent row — it's a domain concept with behavior. How rich this layer is depends on the app's complexity; the next chapter is about calibrating that.
Repositories isolate data access (the repository boundary from the TS track, identical idea). The business layer asks a repository for tickets in domain terms; the repository knows about Eloquent. This keeps Eloquent — a framework concern — from leaking into the business logic, and makes the business logic testable with a fake repository.
The refactored version of the fat controller distributes these concerns:
php
// Thin controller — delegates to an action, shapes the response.
public function store(CreateTicketRequest $request, CreateTicket $action)
{
$ticket = $action->execute($request->toDto()); // business logic lives in the action
return new TicketResource($ticket);
}The business rule now lives in CreateTicket, testable without HTTP, reusable from a job or command, and findable by anyone looking for ticket logic. The concern that was spread across one fat method is partitioned by responsibility — the foundations' domain partitioning, inside Laravel.
One caution as you extract: the win is moving logic out of the controller, not the number of layers you add. The test for whether an extracted layer earns its place comes from Ousterhout's A Philosophy of Software Design: a good module is deep — its interface is much simpler than the work it hides. CreateTicket::execute() is deep, because a one-line call hides a real rule (the open-ticket limit), data access, and side effects. A "service" whose every method forwards one-to-one to Eloquent (createTicket → Ticket::create, findTicket → Ticket::find) is shallow — a pass-through that adds a class without hiding anything, which is the over-engineering this chapter keeps warning about, wearing the costume of "clean architecture." When you extract, check that the new unit's interface is genuinely simpler than its implementation; a method whose body is one line forwarding its arguments is cost without benefit. (Repositories are the deliberate exception: a thin one can still earn its keep by isolating Eloquent behind a swappable, fakeable boundary — a real benefit even when the code is short, which is exactly the judgment the next chapter calibrates.)
Dependency direction
Layering is only half the lesson; the direction of the dependencies between layers is the other half, and it's what keeps the architecture from being layering-in-name-only. The principle, drawn from hexagonal architecture (covered in Domain-Driven Design in PHP) and the broader clean-architecture family: the domain should depend on nothing; the framework should depend on the domain, not the reverse.
Picture the layers as rings, with the domain at the center and the framework at the outside. Dependencies should point inward — controllers depend on services, services depend on the domain, and the domain depends on nothing external. The domain — your business rules and concepts — should not import Eloquent, should not know about HTTP, should not reference framework classes. It's the stable core; everything else is plumbing arranged around it. This is the hexagonal architecture (or "ports and adapters") idea: the domain defines interfaces (ports) for what it needs (a TicketRepository interface), and the framework provides implementations (adapters) that plug into them (an Eloquent-backed TicketRepository). The domain depends on its own interface; the framework depends on the domain to fulfill it.
The payoff is the foundations' evolvability and testability attributes, structurally guaranteed. Because the domain depends on nothing, you can test it in complete isolation, reason about it without the framework in your head, and — in principle — swap the framework around it without rewriting the business logic. That last point is usually theoretical (nobody swaps Laravel for Symfony on a whim), but the discipline it imposes is the real value: a domain that could be swapped is a domain that isn't tangled with the framework, which is a domain that stays clean and changeable. The direction of dependencies is what makes "we have a service layer" into "we have an architecture."
A practical honesty: full hexagonal purity in a Laravel app is often more ceremony than a given project needs, and forcing every Eloquent model behind a domain abstraction can be over-engineering for a CRUD-heavy app (the premature-flexibility trap from Foundations Chapter 1). The judgment — how much separation is worth it — is the subject of the next chapter on DDD-lite. The principle to hold regardless: business logic that matters should not be welded to the framework, and dependencies should point toward your domain, not away from it.
The service container
The mechanism that makes inward-pointing dependencies practical in Laravel is its service container (and dependency injection), and understanding it as an architectural tool rather than framework magic is worth a moment. The container resolves and injects dependencies: when a controller type-hints CreateTicket in its constructor or method, Laravel's container builds and provides it automatically. More importantly for architecture, you can bind an interface to an implementation in the container, so your code depends on the interface and the container supplies the concrete class:
php
// In a service provider — bind the domain's interface to a framework implementation.
$this->app->bind(
TicketRepository::class, // the domain's port (interface)
EloquentTicketRepository::class // the framework's adapter (implementation)
);
// Now a service can depend on the INTERFACE, and the container injects the
// Eloquent implementation — the service never names the framework class.
public function __construct(private TicketRepository $tickets) {}This is exactly what makes the dependency direction work in practice: the service depends on TicketRepository (a domain interface), the container injects EloquentTicketRepository (a framework adapter), and the domain stays ignorant of Eloquent. In tests, you bind a fake implementation instead, and the same service runs against an in-memory repository with no database — the testability payoff, delivered by the container. (Flagged: the service container is Laravel-specific; the dependency-inversion principle it serves is general and comes from the foundations and the DDD book.)
The container is therefore not just convenience plumbing — it's the tool that lets you invert dependencies so the framework points at your domain. Used this way, it's how a Laravel app gets clean boundaries that hold: layers with clear responsibilities, dependencies pointing inward toward a framework-independent domain, and the container wiring the framework adapters to the domain's ports.
That's the chapter: Laravel's conveniences drift toward fat, framework-coupled controllers, and the antidote is layering the app by responsibility, pointing dependencies inward toward a domain that depends on nothing, and using the service container to wire the framework to the domain rather than through it.
Practice
Budget about ninety minutes.
Exercise 1: Find a fat controller (20 min)
In a Laravel app you work on, find a controller method doing too much — validation, business rules, data access, side effects, response shaping all in one place. List each concern it's handling, and note which ones make it impossible to test the business rule in isolation.
Exercise 2: Extract the logic (35 min)
Take that controller and refactor it: move validation to a form request, extract the business logic into a service or action class that doesn't touch HTTP, and thin the controller to delegate-and-respond. Then write a test for the business rule that runs without the HTTP layer. Note what became possible (testing, reuse) once the logic left the controller.
Exercise 3: Invert one dependency (35 min)
Find a service that uses Eloquent directly. Define a repository interface for what it needs, implement it with Eloquent, bind the interface to the implementation in a service provider, and have the service depend on the interface. Then write a test that binds a fake repository instead. Note how the service is now testable without a database — and decide honestly whether this separation is worth it for this case or is over-engineering (a judgment the next chapter develops).
Deliverable
A short before/after for one feature: a fat controller decomposed into thin controller + form request + service/action, with the business logic now testable without HTTP — plus one dependency inverted behind a repository interface wired through the container, with a note on whether the inversion earns its keep here.
Check yourself
- Explain why Laravel's conveniences drift toward fat controllers and the three costs (untestable, non-reusable, change-spreading).
- Describe the layers (controller, service/action, domain, repository) and what belongs in each.
- State the dependency-direction principle and explain the hexagonal ports-and-adapters idea in Laravel terms.
- Explain how the service container enables dependency inversion, and why that delivers testability.
What a strong answer demonstrates: you can name the three concrete costs of the fat controller (untestable, non-reusable, change-spreading) and connect each to a layer that fixes it — not just recite the layer names. The decisive move is explaining direction: layering alone is cosmetic; it becomes architecture only when dependencies point inward at a domain that depends on nothing, with the container binding the framework's adapter to the domain's port. A strong answer also keeps the honesty from the chapter — that full hexagonal purity can be over-engineering for CRUD, and the judgment of how much separation to buy is the real skill.
Going deeper (optional)
- Domain-Driven Design in PHP (Buenosvinos & Soronellas) — the chapters on architectural styles and on hexagonal architecture with PHP are the direct source for the layering and dependency-direction principles here.
- Primary source: Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) — the layered-architecture chapter is the seminal statement of "isolate the domain from infrastructure and UI," the principle the fat-controller fix and inward-pointing dependencies make concrete in Laravel.
- Primary source: Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — the original catalog behind the layering here: Service Layer, Repository, and the layering-principles chapter are the foundational references for what belongs in each tier and why the data-access seam matters.
- The Laravel documentation on the service container, service providers, and form requests for the framework mechanics. (Flagged: Laravel-specific mechanics — container binding, form requests, the service/action conventions — come from Laravel docs and community practice, not the book set.)
Key takeaways
- Laravel's conveniences pull toward fat, framework-coupled controllers, which are untestable, non-reusable, and spread business change into the HTTP layer.
- Layer the app by responsibility: thin controllers + form requests at the edge, services/actions for use cases, a domain for core rules, repositories for data access.
- Keep dependencies pointing inward toward a domain that depends on nothing (hexagonal ports-and-adapters) — the discipline that makes business logic clean, testable, and framework-independent.
- The service container is the architectural tool that inverts dependencies — bind interfaces to implementations so the framework points at your domain, delivering testability and changeability.
Next: Domain Modeling & DDD-lite — calibrating how rich your domain layer should be, and the pragmatic middle between anemic models and full DDD.