Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

SOLID Principles With Practical PHP Examples (and When to Ignore Them)

About Post

Most of us learned SOLID with rectangles, squares and ducks. Then we went back to work, opened a 900-line controller, and had no idea which duck applied.

The opposite problem is just as real. Someone reads about SOLID on a Friday, and by Monday a simple "send invoice" feature has an interface, an abstract class, a factory, a strategy and a provider, all with exactly one implementation.

SOLID is useful. But it's a set of answers to specific pains, and it only makes sense when you can recognise the pain. So for each principle, here's the smell in real PHP, a small fix, and the point where I'd stop.

S: Single Responsibility

A class should have one reason to change. Not "do one thing" (that's too vague), but serve one kind of change request.

The smell

class InvoiceController extends Controller
{
    public function store(Request $request)
    {
        // 1. validate input           (changes when the form changes)
        // 2. calculate totals and VAT (changes when finance rules change)
        // 3. render the PDF           (changes when the design changes)
        // 4. email it to the tenant   (changes when the wording changes)
    }
}

Four different people can ask you to change this method for four different reasons. Every change risks breaking the other three, and none of it can be reused from a queued job or a console command.

The fix

public function store(StoreInvoiceRequest $request, CreateInvoice $createInvoice)
{
    $invoice = $createInvoice->handle($request->validated());

    $invoice->tenant->notify(new InvoiceIssued($invoice));

    return new InvoiceResource($invoice);
}

Validation moves to a form request, the business logic to an action class, the email to a notification, and the PDF to whatever builds it. The controller just coordinates.

When to stop: a 30-line controller method that's only ever called from one place doesn't need splitting into five classes. Split when the reasons to change actually start colliding.

O: Open/Closed

Open for extension, closed for modification. Adding a new case shouldn't mean editing tested code that already works.

The smell

public function charge(Payment $payment): void
{
    match ($payment->method) {
        'card' => $this->chargeCard($payment),
        'bank_transfer' => $this->recordTransfer($payment),
        'cheque' => $this->recordCheque($payment),
        // every new method means editing this class again
    };
}

The fix

interface PaymentMethod
{
    public function charge(Payment $payment): void;
}

final class PaymentProcessor
{
    /** @param array<string, PaymentMethod> $methods */
    public function __construct(private array $methods) {}

    public function charge(Payment $payment): void
    {
        $this->methods[$payment->method]->charge($payment);
    }
}

A new payment method is now a new class plus one line of registration in a service provider. The processor never changes.

When to stop: a match with three stable cases is perfectly fine and easier to read. Reach for this when the list keeps growing or each branch is getting long.

L: Liskov Substitution

A subclass must be usable anywhere its parent is, without surprises. This is the one people skip, and it causes the strangest bugs.

The smell

class Notifier
{
    public function send(User $user, string $message): void { /* email */ }
}

class SmsNotifier extends Notifier
{
    public function send(User $user, string $message): void
    {
        if (! $user->phone) {
            throw new LogicException('User has no phone number');
        }
        // send SMS
    }
}

Code written against Notifier never expected an exception for a missing phone. Swap in SmsNotifier and a loop over all users dies halfway through.

Here's the subtle part: PHP checks that the signatures are compatible, but it can't check the promises. A subclass that throws where the parent didn't, ignores an argument, or returns a "special" null breaks Liskov while compiling perfectly.

The fix

Make the contract honest. Either the interface says up front that some users can't be reached (for example a canReach(User $user): bool method callers check first), or SmsNotifier handles the case itself by skipping and logging. Either way, the caller never has to know which implementation it got.

I: Interface Segregation

Don't force classes to implement methods they don't need.

The smell

interface Exportable
{
    public function toCsv(): string;
    public function toPdf(): string;
    public function toExcel(): string;
}

class AuditLog implements Exportable
{
    public function toPdf(): string
    {
        throw new BadMethodCallException('Not supported');
    }
    // ...
}

A method that throws "not supported" is a sign the interface is too big. It also breaks Liskov, which is why these two principles often appear together.

The fix

Split it: ExportsCsv, ExportsPdf, ExportsExcel. A class implements what it really supports, and code that only needs CSV asks only for ExportsCsv.

Laravel does this well. ShouldQueue, ShouldBeUnique and ShouldBroadcast are tiny interfaces you opt into one at a time, not one giant Job contract.

D: Dependency Inversion

Depend on abstractions, not on concrete details. High-level business code shouldn't know which SMS provider or storage service you picked this year.

The smell

class SendRentReminder
{
    public function handle(Tenant $tenant): void
    {
        $client = new AcmeSmsClient(config('services.acme.key'));
        $client->send($tenant->phone, 'Your rent is due soon.');
    }
}

You can't test this without sending a real SMS, and changing provider means editing business logic.

The fix

class SendRentReminder
{
    public function __construct(private SmsSender $sms) {}

    public function handle(Tenant $tenant): void
    {
        $this->sms->send($tenant->phone, 'Your rent is due soon.');
    }
}

// In a service provider:
$this->app->bind(SmsSender::class, AcmeSmsSender::class);

Laravel's container injects the right implementation, and in tests you bind a fake that records messages instead of sending them.

When to stop: you don't need an interface in front of Eloquent, the request object or every internal class. Put abstractions at the boundaries that really vary or are painful to test: payment gateways, SMS, storage, external APIs, AI providers.

My rule of thumb: apply a SOLID principle when you can name the pain it removes today. "We might need it one day" is how simple apps end up with 40 files per feature.

The cheat sheet

PrincipleThe smellTypical fix in Laravel
Single ResponsibilityOne class changes for many unrelated reasonsForm requests, action classes, notifications
Open/ClosedA match or if chain you edit for every new caseInterface plus registered implementations
Liskov SubstitutionA subclass that throws or behaves "specially"Honest contracts, no surprise exceptions
Interface SegregationMethods that throw "not supported"Small, opt-in interfaces
Dependency Inversionnew SomeClient() inside business logicConstructor injection and container bindings

SOLID isn't a checklist to pass. It's a vocabulary for explaining why some code hurts to change. Use it to name the pain, fix that, and stop there.

Which principle do you see broken most often in real codebases? My vote goes to the single-responsibility controller that also sends emails.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close