SOLID in Laravel: Useful Boundaries, Not Ceremony

3 min readUpdated on

SOLID is a vocabulary for change pressure. Single responsibility means an action has one reason to change. Open/closed means add a pricing rule through a strategy rather than edit a central conditional. Dependency inversion means depend on a meaningful contract where multiple implementations or tests need it—not make interfaces for every model.

Laravel’s container resolves constructor dependencies, but it does not automatically make a design modular. Prefer concrete classes until a real seam appears, keep contracts narrow, and prove the before/after code with a test. The goal is easier change, not a taller folder tree.

One responsibility means one kind of change

Keep controllers as HTTP adapters. Put a reusable use case in a typed action so it can run from an API, command or job without recreating HTTP state.

php
<?php

declare(strict_types=1);

final readonly class CreateInvoice
{
    public function __construct(private InvoiceNotifier $notifier) {}

    public function handle(User $user, int $amount): Invoice
    {
        $invoice = Invoice::query()->create(['user_id' => $user->getKey(), 'amount' => $amount]);
        $this->notifier->sendCreated($invoice);

        return $invoice;
    }
}

Extend variation; do not centralise it

A match is right for two stable cases. Extract a contract when behaviour arrives independently, so each rule is testable without the others.

php
<?php

declare(strict_types=1);

interface TaxCalculator
{
    public function calculate(int $netCents): int;
}

final readonly class PolishTaxCalculator implements TaxCalculator
{
    public function calculate(int $netCents): int
    {
        return intdiv($netCents * 23, 100);
    }
}

What the container actually does for DIP

The container resolves constructor dependencies; it does not select a runtime implementation merely because it sees an interface. Bind a default implementation when every consumer needs it. Use contextual binding only for a known consumer, and a resolver or factory when the selection depends on tenant, country or request data.

php
<?php

declare(strict_types=1);

use Illuminate\Support\ServiceProvider;

final class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(TaxCalculator::class, PolishTaxCalculator::class);
    }
}

app(TaxCalculator::class) now resolves because it has a concrete binding. Do not inject the container into domain code to defer the decision; inject a TaxCalculatorResolver when selection is runtime data.

LSP, ISP and when not to force SOLID

Do not make ReadOnlyStorage implement Storage if delete() must throw: split the capability into read() and write() contracts. Equally, do not create an interface for one stable Eloquent repository or a one-off action. Add a boundary for changing behaviour, an external dependency to fake, or an independent policy. The alternative is a focused concrete class, not tangled code.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.