Most SOLID principles PHP write-ups stay stuck in theory — five letters, five definitions, no code you would actually ship. That framing is wrong. Every one of them maps to a concrete Laravel refactor I have done to stop a class from becoming impossible to change. The value is not the acronym; it is that each principle removes a specific kind of pain: a controller you are scared to touch, a match statement that grows every sprint, a mock that needs ten methods to test one. Below is one real example per letter, with the gotcha that actually bites you in production.
SRP: why is my controller doing three jobs at once?
The Single Responsibility Principle says a class should have one reason to change. The classic violation is the fat controller: it validates input, charges a card, and sends a receipt email all in one method. Three responsibilities means three reasons to change, and every change risks the other two. It is also untestable — you cannot assert the receipt logic without also hitting the payment gateway.
Here is the kind of action method I inherit on almost every legacy project. It reads fine until you need to change the email template and discover you have to spin up Stripe to run the test.
public function store(Request $request)
{
$data = $request->validate([
'items' => 'required|array',
'card_token' => 'required|string',
]);
// responsibility 1: charge the card
$charge = $this->stripe->charges->create([
'amount' => $this->total($data['items']),
'currency' => 'usd',
'source' => $data['card_token'],
]);
// responsibility 2: persist the order
$order = Order::create([
'charge_id' => $charge->id,
'total' => $charge->amount,
]);
// responsibility 3: send the receipt
Mail::to($request->user())->send(new ReceiptMail($order));
return redirect()->route('orders.show', $order);
}The fix is to push each responsibility into a focused class and let the controller do only what controllers are for: translate an HTTP request into a call and a response. The charging logic goes into a service, persistence stays in the model, and the receipt becomes a queued notification. The controller shrinks to a few lines and each piece is now testable in isolation. I go deeper on how to structure that service layer in my write-up on testable service classes in Laravel.
OCP: how do I stop editing the same match statement every sprint?
The Open/Closed Principle says code should be open for extension but closed for modification. The tell-tale violation is a match or switch that grows a new arm every time the business adds a feature. Each edit reopens a file that already works and risks regressing every existing branch. Payment gateways are the obvious example: today it is Stripe and PayPal, next quarter it is a local provider, and you are back in the same method adding another case.
Instead of branching inside one method, define a contract and a class per strategy, then resolve the right one from the container. Adding a gateway becomes adding a class and one binding — you never touch the existing code that already passes its tests.
interface PaymentGateway
{
public function charge(int $amountInCents, string $token): ChargeResult;
}
final class StripeGateway implements PaymentGateway
{
public function charge(int $amountInCents, string $token): ChargeResult
{
// Stripe-specific call
}
}
final class PayPalGateway implements PaymentGateway
{
public function charge(int $amountInCents, string $token): ChargeResult
{
// PayPal-specific call
}
}The resolver picks the implementation by key. The match here only ever maps a string to a class name — it holds no business logic, so growing it is cheap and safe. Better still, register a tagged map in a service provider and drop the match entirely.
final class GatewayResolver
{
/** @param array<string, class-string<PaymentGateway>> $map */
public function __construct(
private Container $container,
private array $map,
) {}
public function for(string $provider): PaymentGateway
{
$class = $this->map[$provider]
?? throw new \InvalidArgumentException("Unknown gateway [$provider]");
return $this->container->make($class);
}
}I inject Illuminate\Contracts\Container\Container rather than calling the global app() helper, so the resolver stays testable and its dependency on the container is explicit. The gotcha: people implement the strategy pattern and then leave a fat match in the controller that decides which strategy to use, which just moves the violation. The decision has to be data-driven — a config map or a column on the model — so adding a strategy never reopens the dispatch logic. This is the same pattern I reach for when consolidating inbound events, which I cover in centralizing payment webhooks in one Laravel controller.
LSP: when does a subtype quietly break its parent's contract?
The Liskov Substitution Principle says a subtype must be usable anywhere its parent type is expected, without surprises. It is the one that bites silently. Say you have a Rectangle with setWidth and setHeight, and you make Square extend it by overriding both setters to keep the sides equal. It compiles, it passes a naive unit test, and then code that does setWidth(5); setHeight(4) and expects an area of 20 gets 16 — because the Square mutated width when you set height. The subtype honoured the method signatures but broke the behavioural contract callers relied on.
In real PHP this shows up as an overridden method that narrows what the parent accepts or widens what it throws. A common one: a base Repository whose find() returns the model or null, and a subclass that throws instead of returning null. Every caller that wrote a null check is now wrong, and you will not find out until production. The fix is composition over inheritance — Square and Rectangle are simply not substitutable, so do not force an is-a relationship the contract cannot support.
ISP: why should a class depend on methods it never calls?
The Interface Segregation Principle says no client should be forced to depend on methods it does not use. The violation is the fat interface — one big contract that bundles unrelated capabilities. The damage is concrete in tests: to mock a five-method interface for a consumer that calls one method, you still have to stub all five, and any change to the unused methods churns every implementer.
- A fat ReportRepository with generate(), export(), schedule(), email(), and archive() forces a class that only needs generate() to know about scheduling and email.
- Split it into focused contracts — ReportGenerator, ReportExporter, ReportScheduler — and let each consumer type-hint exactly the slice it uses.
- A class can still implement several of them; segregation is about what the consumer depends on, not how many interfaces the concrete class satisfies.
- The payoff is in testing: a small interface means a small mock, and a small mock means the test states clearly what the unit actually needs.
Laravel itself models this well — look at how its contracts are sliced into tiny single-method interfaces like Illuminate\Contracts\Support\Arrayable, which declares only toArray(), rather than one god-interface. When you design your own boundaries, copy that instinct.
DIP: how do I bind an interface to a concrete in Laravel?
The Dependency Inversion Principle says high-level code should depend on abstractions, not concretions. In Laravel this is the most directly actionable letter, because the service container is built for it. Type-hint an interface in your constructor, bind the concrete implementation once in a service provider, and the container injects it everywhere. Swapping the implementation — a real SMS provider for a fake in tests, or one vendor for another — becomes a one-line change in the provider with zero edits to consumers.
use App\Payments\PaymentGateway;
use App\Payments\StripeGateway;
use App\Payments\PayPalGateway;
public function register(): void
{
// Bind by config so the environment chooses the concrete, once.
$this->app->bind(PaymentGateway::class, function ($app) {
return match (config('services.payments.driver')) {
'paypal' => $app->make(PayPalGateway::class),
default => $app->make(StripeGateway::class),
};
});
}Now any class just asks for the abstraction and the container does the wiring:
final class CheckoutService
{
public function __construct(
private readonly PaymentGateway $gateway,
) {}
public function pay(Order $order, string $token): ChargeResult
{
return $this->gateway->charge($order->total, $token);
}
}The gotcha that catches people: DIP is not 'put an interface on everything'. An abstraction you only ever have one implementation of, and never swap or mock, is pure ceremony — it adds a file and an indirection for no payoff. Introduce the interface when you have a real second implementation, a real test double, or a real volatile dependency like a third-party API. When you start splitting a system across process boundaries, this discipline becomes load-bearing — I lean on it heavily in my notes on Laravel microservices architecture.
SOLID is not a checklist you pass — it is a set of escape hatches you reach for the moment a class becomes painful to change.
So when should I actually apply all this?
Apply these principles to relieve real pain, not to gold-plate. Every abstraction has a carrying cost: another file to open, another layer to trace, another indirection a new teammate has to hold in their head. If a class has one responsibility, one implementation, and changes once a year, leave it alone — wrapping it in an interface and a strategy resolver makes the codebase worse, not better. The signal to refactor is concrete: you are scared to edit a method, you are mocking a gateway to test an email, or you are reopening the same match every sprint. That is when SOLID earns its keep. Reach for the right letter to kill the specific pain in front of you, ship it, and move on — maintainability is the goal, and over-engineering is just a slower way to miss it.

