Step 2 of the checkout refactor: OrderPlaced::dispatch($order) feeds a SendOrderReceipt listener, struck out. With that listener deleted, 1 of 7 Pest tests fails in our 4 October 2026 run on Laravel 13.34.0, so the green runs mean something.

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.

  1. 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.

  2. 1994

    Liskov and Wing make it formal

    What holds for the parent type must hold for the child.

  3. 2000

    Martin, Design Principles and Design Patterns

    Four of the five principles set down in one paper, open/closed and dependency inversion among them.

  4. 2014

    Martin restates single responsibility

    Gather together the things that change for the same reasons.

  5. 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.

Non-blank lines of PHP after step 5: the controller against the whole checkout pathsmaller controller, more code

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.

Non-blank lines of PHP after step 5: the controller against the whole checkout path (non-blank lines of PHP, measured on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026)
Optionnon-blank lines of PHP, measured on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026
CheckoutController after step 518 lines
Whole checkout path after step 5155 lines

Source: Atyantik first-party measurement, 4 October 2026

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".

Each SOLID principle and the Laravel feature that carries it. Source: Laravel 13.x documentation, accessed 4 October 2026

PrincipleLaravel featureUse it when
Single responsibilityForm requests, policiesA controller validates input or checks permissions inline
Open/closedEvents, middlewareYou keep adding side effects or request checks to one method
Liskov substitutionContracts plus one shared testTwo classes must stand in for each other, like a real and a fake gateway
Interface segregationYour own narrow interfacesA caller needs one method from a wide interface
Dependency inversionService containerA 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.

php
// 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.

php
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
Files in the checkout path after each refactor step, measured on Laravel 13.34.0, 4 October 2026
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.

Figure Files in the checkout path after each refactor step, measured on Laravel 13.34.0, 4 October 2026 Atyantik first-party measurement, checkout refactor run on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026
DiagramCheckout dependencies at step 0 and at step 5. Source: Atyantik first-party measurement, checkout refactor run on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026
Try it
Pick a refactor step

Steps run in the order S, O, D, L, I; the same Pest test ran after each one.

18 (no change)
11 (+1)
155 (+5)
7 (no change)

Past the stop rule: files added, no reason to change removed

Stop here

Interface 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.

app/Payments/ChargesPayments.php
<?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;
}
Every step, measured on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026
StepController linesFilesLinesTests
Fat controller461463
Single responsibility185833
Open/closed1871033
Dependency inversion1891303
Liskov substitution18101507
Interface segregation18111557
Choose a step from 0 to 5 to see the code that changed, the controller lines, the files and lines in the checkout path, the Pest result and whether the step passed the stop rule. Counts measured on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026; the stop-rule verdict is modelled, not measured.

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.

php
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.

php
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
Pest tests passing after each step; the contract test adds four at step 4. Measured on Laravel 13.34.0, 4 October 2026
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.

Figure Pest tests passing after each step; the contract test adds four at step 4. Measured on Laravel 13.34.0, 4 October 2026 Atyantik first-party measurement, checkout refactor run on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026

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.

  1. Validation: the form request and its rules() method.

  2. Events: the event, the listener and how Laravel finds listeners.

  3. Service container: binding an interface in register().

  4. Mocking: faking what a test should not run.

  5. 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.

bash
# 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.

Repository around Eloquent: when it earns its files. Source: Laravel 13.x Eloquent documentation, accessed 4 October 2026

OptionUse it whenCost
Eloquent model with local scopesOne database, and queries you want to re-useA method on the model
Repository interface plus implementationA second store is real, such as an external API beside the database, or a query is too large for a scopeTwo 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
Lines of PHP added to the checkout path at each step, from 46 to 155; step 5 bought the least. Measured on Laravel 13.34.0, 4 October 2026
Step Change Running total
Step 0: fat controller 46 lines46 lines
Step 1: S 37 lines83 lines
Step 2: O 20 lines103 lines
Step 3: D 27 lines130 lines
Step 4: L 20 lines150 lines
Step 5: I 5 lines155 lines
After step 5 155 lines

Step 5 added the fewest lines, 5, while checkout still called only charge.

Figure Lines of PHP added to the checkout path at each step, from 46 to 155; step 5 bought the least. Measured on Laravel 13.34.0, 4 October 2026 Atyantik first-party measurement, checkout refactor run on Laravel 13.34.0 and PHP 8.5.11, 4 October 2026

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.

Questions this post answers

Are the SOLID principles in Laravel related to SolidJS?
No. SolidJS is a JavaScript UI library that shares only the word; the SOLID principles are five object-oriented design rules for classes, in PHP or any other language.
Where do the SOLID principles come from?
Robert C. Martin collected them; he set four of the five down in his 2000 paper Design Principles and Design Patterns. Liskov substitution comes from Barbara Liskov's 1987 keynote, Data Abstraction and Hierarchy, made formal by Liskov and Wing in 1994.
Should Laravel controllers be thin?
Usually, yes: a thin controller hands validation to a form request and the work to an action class, so each reason to change lives in its own class. 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.
What is the single responsibility principle 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. Put simply, the single responsibility principle in PHP is about who asks for a change, not how long a class is.
Do you need an interface for every service class in Laravel?
No. 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.
Are Laravel facades a violation of the SOLID principles?
No. Laravel's facades docs call them static proxies that stay testable, but 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.

Keep reading