SOLID principles in Laravel: refactor one fat controller, a principle at a time
Five old design rules, one checkout controller and the same test after every change. You see what each rule buys, what it costs in files, and where to stop before it turns into ceremony.
What are the SOLID principles in Laravel?#
The SOLID principles in Laravel are five object-oriented design rules, collected by Robert C. Martin, that the framework already applies through its service container, contracts, form requests, events, middleware and policies. Martin set four of them down in his 2000 paper Design Principles and Design Patterns. There he writes "A module should be open for extension but closed for modification." Then he adds "Depend upon Abstractions. Do not depend upon concretions."
SOLID stands for single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion. Later, in a 2014 essay, Martin put single responsibility as "Gather together the things that change for the same reasons." Liskov substitution comes from Barbara Liskov's 1987 keynote, Data Abstraction and Hierarchy. In practice, Laravel calls its service container "a powerful tool for managing class dependencies and performing dependency injection". Most of the work is already in the box, and none of it is Laravel-only: the SOLID principles in PHP are the same five rules, and Laravel just gives each one a home.
But one name clash needs clearing up. SolidJS is a JavaScript UI library, and it shares only the word.
- October 1987
Liskov, Data Abstraction and Hierarchy
The keynote behind Liskov substitution: if programs behave the same when one object replaces another, then S is a subtype of T.
- 1994
Liskov and Wing make it formal
What holds for the parent type must hold for the child.
- 2000
Martin, Design Principles and Design Patterns
Four of the five principles set down in one paper, open/closed and dependency inversion among them.
- 2014
Martin restates single responsibility
Gather together the things that change for the same reasons.
- 17 March 2026
Laravel 13 released
The major version the refactor in this post ran on, as Laravel 13.34.0 with PHP 8.5.11.
What does a SOLID refactor change in a Laravel controller?#
On Laravel 13.34.0 the example checkout controller shrank from 46 lines to 18, while the checkout code as a whole grew from one 46-line file to 11 files and 155 lines. Those counts come from our run of 4 October 2026, with blank lines left out. Laravel 13 itself was released on 17 March 2026, according to the Laravel release notes.
That is the trade. You get a controller where each reason to change lives in its own class. You pay for it in files and in total code. In return, a change to payments no longer touches validation or email, and a pull request stays small.
18 lines
CheckoutController after step 5
155 lines
Whole checkout path after step 5
The controller fell from 46 lines to 18 while the checkout path grew from 46 to 155.
| Option | non-blank lines of PHP, measured on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026 |
|---|---|
| CheckoutController after step 5 | 18 lines |
| Whole checkout path after step 5 | 155 lines |
Which Laravel features already apply each SOLID principle?#
Laravel ships at least one feature per principle: form requests and policies for single responsibility, middleware and events for open/closed, contracts for substitution and segregation, and the service container for dependency inversion. For example, the contracts docs call contracts "a set of interfaces that define the core services provided by the framework".
| Principle | Laravel feature | Use it when |
|---|---|---|
| Single responsibility | Form requests, policies | A controller validates input or checks permissions inline |
| Open/closed | Events, middleware | You keep adding side effects or request checks to one method |
| Liskov substitution | Contracts plus one shared test | Two classes must stand in for each other, like a real and a fake gateway |
| Interface segregation | Your own narrow interfaces | A caller needs one method from a wide interface |
| Dependency inversion | Service container | A class builds its own HTTP client, mailer or vendor SDK |
Source: Laravel 13.x documentation, accessed 4 October 2026.
The docs pages behind the table: validation, authorization, events, middleware, contracts and the service container.
Reach for the framework feature first. Only when none fits do you write a layer of your own.
How does the Laravel service container apply dependency inversion?#
The service container resolves a type-hinted interface to whichever class you bind in a service provider, so a class depends on the abstraction and one line in the provider picks the concretion. Laravel's container docs cover this under binding interfaces to implementations.
Below is the binding the refactor adds at step 3. Any class that asks for PaymentGateway in its constructor gets the HTTP version, so the checkout depends on an abstraction, never on the HTTP client. A test double alone does not justify the interface, though: Http::fake() and Mail::fake() already make the step 0 controller testable. What justifies it is a second real gateway.
// app/Providers/AppServiceProvider.php (step 3 of the refactor)
public function register(): void
{
$this->app->bind(PaymentGateway::class, fn () => new HttpPaymentGateway(
config('services.payments.url'),
));
} How do you refactor a fat Laravel controller one principle at a time?#
Take the steps in the order S, O, D, L, I, run the same Pest test after each one, and stop the moment a step adds a file without removing a reason to change. The example was written to teach and comes from no client codebase.
First, the starting point. This controller validates, prices the cart, charges a card over HTTP, saves the order and emails a receipt. In our run of 4 October 2026, it was 46 lines with three facades.
class CheckoutController extends Controller
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email'],
'payment_token' => ['required', 'string'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
]);
$prices = Product::whereIn('id', collect($data['items'])->pluck('product_id'))
->pluck('price_cents', 'id');
$total = collect($data['items'])
->sum(fn ($item) => $prices[$item['product_id']] * $item['quantity']);
$charge = Http::post(config('services.payments.url'), [
'amount' => $total,
'source' => $data['payment_token'],
]);
if ($charge->failed() || $charge['status'] !== 'succeeded') {
return response()->json(['message' => 'Payment declined.'], 402);
}
$order = DB::transaction(fn () => Order::create([
'email' => $data['email'],
'total_cents' => $total,
'charge_id' => $charge['id'],
]));
Mail::to($order->email)->send(new OrderReceipt($order));
return response()->json(['order_id' => $order->id], 201);
}
} Second, write the test before you touch it. Ours has three cases: a paid order is saved and emailed, an empty cart fails before any charge, and a declined card saves and sends nothing. Because that file never changed, its checksum matched at every step.
Show data table
| Item | Value |
|---|---|
| Step 0: fat controller | 1 |
| Step 1: S, single responsibility | 5 |
| Step 2: O, open/closed | 7 |
| Step 3: D, dependency inversion | 9 |
| Step 4: L, Liskov substitution | 10 |
| Step 5: I, interface segregation | 11 |
Every step added files, from 1 at the start to 11 at step 5.
CheckoutController lines
18 (no change)Files in the checkout path
11 (+1)Lines in the checkout path
155 (+5)Pest tests passing
7 (no change)Past the stop rule: files added, no reason to change removed
Stop hereInterface segregation: The checkout code still calls only one method of what it depends on.. Against the start, the controller is 28 lines shorter and the checkout path is 109 lines longer.
<?php
namespace App\Payments;
interface ChargesPayments
{
/**
* Charge the amount and return the gateway's charge id.
*
* @throws PaymentDeclined when the charge does not succeed
*/
public function charge(int $amountCents, string $token): string;
}| Step | Controller lines | Files | Lines | Tests |
|---|---|---|---|---|
| Fat controller | 46 | 1 | 46 | 3 |
| Single responsibility | 18 | 5 | 83 | 3 |
| Open/closed | 18 | 7 | 103 | 3 |
| Dependency inversion | 18 | 9 | 130 | 3 |
| Liskov substitution | 18 | 10 | 150 | 7 |
| Interface segregation | 18 | 11 | 155 | 7 |
What does the single responsibility principle look like in PHP?#
In PHP the single responsibility principle means one class per reason to change, so the checkout's validation moves to a form request and its pricing and ordering move to two action classes. Validation changes when the form changes. Pricing changes when discounts change. So they live apart. Put simply, the single responsibility principle in PHP is about who asks for a change, not how long a class is.
For instance, Laravel's validation docs say form requests "encapsulate their own validation and authorization logic". In our 4 October 2026 run, the controller was 18 lines after step 1. Pest still passed 3 tests with 14 assertions.
class CheckoutController extends Controller
{
public function store(CheckoutRequest $request, PlaceOrder $placeOrder): JsonResponse
{
try {
$order = $placeOrder($request->validated());
} catch (PaymentDeclined $e) {
return response()->json(['message' => $e->getMessage()], 402);
}
return response()->json(['order_id' => $order->id], 201);
}
} This is where most of the gain sits. The controller never changed again in the four steps after it.
How do events, an interface and a contract test cover O, D, L and I?#
An OrderPlaced event closes checkout to new side effects, a bound PaymentGateway interface inverts the payment dependency, a shared contract test enforces substitution, and splitting that interface keeps checkout off refunds. Each step ran green before the next began.
First, step 2 swaps the mail call for OrderPlaced::dispatch($order) and moves the email into a SendOrderReceipt listener. A new side effect is now a new listener, and checkout stays closed. Step 3 adds the PaymentGateway interface shown above. We gave it a refund() method that checkout never calls, so step 5 has something real to split.
Next comes Liskov at step 4. In Data Abstraction and Hierarchy (October 1987), Liskov says that if programs behave the same when one object replaces another, "then S is a subtype of T". Liskov and Wing made that formal in their 1994 ACM TOPLAS paper, A Behavioral Notion of Subtyping: what holds for the parent type must hold for the child. Because a rule is only useful when checked, a shared Pest dataset runs one test against both gateways.
dataset('gateways', [
'http' => fn () => new HttpPaymentGateway('https://payments.test/charges'),
'fake' => fn () => new FakePaymentGateway,
]);
beforeEach(function () {
Http::fake([
'payments.test/charges' => fn ($request) => $request['source'] === 'tok_declined'
? Http::response(['status' => 'declined'], 402)
: Http::response(['id' => 'ch_http_1', 'status' => 'succeeded']),
]);
});
it('throws PaymentDeclined on a declined card, never returns', function ($gateway) {
$gateway->charge(1000, 'tok_declined');
})->with('gateways')->throws(PaymentDeclined::class); Step 5 splits the interface into ChargesPayments and RefundsPayments, since checkout only charges. Martin's 2000 paper says "Many client specific interfaces are better than one general purpose interface". By our own stop rule, step 5 is the one to question: it added a file and 5 lines, and checkout gained nothing it could not already do. Make it only once something really refunds.
Show data table
| Stage | Value |
|---|---|
| Step 0: fat controller | 3 |
| Step 1: S, single responsibility | 3 |
| Step 2: O, open/closed | 3 |
| Step 3: D, dependency inversion | 3 |
| Step 4: L, Liskov substitution | 7 |
| Step 5: I, interface segregation | 7 |
The same 3 tests passed at every step, and the contract test took the count to 7 at step 4.
But does the test catch real breakage? Delete the receipt listener from the final code, and 1 of 7 tests fails in the 4 October 2026 run. The green runs mean something.
Which Laravel docs should you build this refactor from?#
Build it from five Laravel 13 pages in this order: validation for form requests, events, the service container, mocking for fakes, and testing for running Pest. Each gives you one move.
Validation: the form request and its rules() method.
Events: the event, the listener and how Laravel finds listeners.
Service container: binding an interface in register().
Mocking: faking what a test should not run.
Testing: running the suite with php artisan test.
The five pages: Validation, Events, Service container, Mocking and Testing, Laravel 13.x documentation, accessed 4 October 2026.
Pest's installation guide helps if the project still runs plain PHPUnit.
What is the smallest SOLID change worth making in a Laravel app?#
The smallest worthwhile change is moving validation into a form request and the work into one action class, with a Pest test written first so behaviour cannot drift. That is step 1 alone, and in our run of 4 October 2026 it took the controller from 46 lines to 18.
# 1. Pin today's behaviour with a feature test, before any move
php artisan make:test CheckoutTest
# 2. Move the validation rules into a form request's rules() method
php artisan make:request CheckoutRequest
# 3. Put the work in app/Actions/PlaceOrder.php and type-hint it on the controller method
# 4. Run the same test after every move, and never edit it
php artisan test Because the test comes first, a slip shows up as a red run, not a support ticket.
Should you use the repository pattern with Eloquent?#
Usually not: an Eloquent model is already the data-access layer, so a repository interface with one Eloquent implementation adds two files per model without adding a second way to store data. Our checkout never needed one, and its test ran on a real database the whole way.
| Option | Use it when | Cost |
|---|---|---|
| Eloquent model with local scopes | One database, and queries you want to re-use | A method on the model |
| Repository interface plus implementation | A second store is real, such as an external API beside the database, or a query is too large for a scope | Two files per model |
For shared queries, Eloquent has its own answer. The Eloquent docs say local scopes "allow you to define common sets of query constraints that you may easily re-use". However, a repository earns its files when a second store is real, such as an external API beside the database. It also helps when a query grows too large for a scope, and toRawSql() prints the full SQL such a query sends.
When is SOLID overkill in a Laravel app?#
Skip an interface until a second real implementation needs it, keep facades in small apps, and stop when a step adds files faster than it removes reasons to change. Our 4 October 2026 run shows it. Step 5 added a file and 5 lines, while checkout still called only charge.
Show data table
| Step | Change | Running total |
|---|---|---|
| Step 0: fat controller | 46 lines | 46 lines |
| Step 1: S | 37 lines | 83 lines |
| Step 2: O | 20 lines | 103 lines |
| Step 3: D | 27 lines | 130 lines |
| Step 4: L | 20 lines | 150 lines |
| Step 5: I | 5 lines | 155 lines |
| After step 5 | 155 lines |
Step 5 added the fewest lines, 5, while checkout still called only charge.
Treat Laravel SOLID principles as tools, not laws. Facades are not the enemy either. Laravel's facades docs say they "serve as \"static proxies\" to underlying classes" and stay testable. The danger the docs name is class "scope creep": a facade hides each new dependency, while injection lists it in the constructor. So inject in a class that keeps gaining collaborators, and keep facades in a small controller.
The evidence is narrower than the debate. In a controlled study at CAIN 2024, Cabral and colleagues gave SOLID and non-SOLID machine learning code to 100 data scientists. They found the SOLID version easier to understand, but that is one study on one kind of code.
Where should you go next with SOLID in Laravel?#
For the PHP features the final code uses, the SaaS apps it usually lives in, and automated refactoring with Rector, three related posts go further. Start with the PHP features that make code like this shorter, then why Laravel suits SaaS products. For the mechanical half of a cleanup, see Rector for PHP migrations.
Two more sit beside them: a review of Tenancy for Laravel and clean code practices in JavaScript. If your team wants help taking a large codebase through these steps, you can hire a Laravel developer or ask about ongoing maintenance and support. For most apps, though, the Laravel docs above are enough on their own.