SOLID is one of those topics that shows up in every interview and almost nowhere in code reviews. Most developers can recite the five letters. Far fewer can point to the moment in a real project where one of the principles saved them, or where ignoring it cost a week of work.
This guide skips the textbook shapes and animals. Each principle is explained with the kind of code that actually ships: Laravel services, React components, payment integrations and ERP-style reports. For each one you will see the problem it solves, a small before-and-after example, and an honest note on when it is not worth applying.
What SOLID Is Really About
The five principles were collected and popularised by Robert C. Martin, and the acronym itself was coined by Michael Feathers. Under the names, they all push toward one goal: make change cheap and local. When a requirement changes, you want to touch one place, with a small blast radius, and be confident nothing else broke.
Keep that goal in mind and the principles stop feeling like rules to obey. They become questions to ask when code starts to hurt.
S: Single Responsibility Principle
A class or module should have one reason to change. "Reason to change" usually means one person or team whose requests would force you to edit it. It does not mean "a class may only do one tiny thing".
The production problem
A typical Laravel controller that creates an order often validates input, calculates totals and tax, saves rows, charges a card, sends an email and writes an audit log. Finance changes the tax rule, marketing changes the email, and operations changes the audit format, and all three edit the same method. Merge conflicts and regressions follow.
// Before: one method, many reasons to change
public function store(Request $request)
{
$data = $request->validate([...]);
$total = collect($data['items'])->sum(fn ($i) => $i['qty'] * $i['price']);
$total += $total * 0.17; // tax rule hard-coded here
$order = Order::create([...$data, 'total' => $total]);
Mail::to($request->user())->send(new OrderPlaced($order));
AuditLog::create(['action' => 'order.created', 'ref' => $order->id]);
return new OrderResource($order);
}After splitting by reason to change, the controller only coordinates. Pricing lives in a PriceCalculator, persistence in an OrderService, and side effects such as email and auditing hang off an OrderPlaced event with their own listeners.
// After: the controller coordinates, each rule has one home
public function store(StoreOrderRequest $request, OrderService $orders)
{
$order = $orders->place($request->user(), $request->validated());
return new OrderResource($order);
}When not to bother: a 30-line script or a CRUD screen nobody else touches. If you are unsure where to draw the lines, the article on controller vs service vs repository walks through it in more detail.
O: Open/Closed Principle
Code should be open for extension but closed for modification. In practice: adding a new variant should mean adding new code, not editing a growing if/else chain that already works.
The production problem
An ERP export starts with CSV. Then someone needs Excel, then PDF, then a bank-specific fixed-width format. Each one adds a branch to the same function, and each edit risks breaking the formats that were already fine.
interface ReportExporter
{
public function supports(string $format): bool;
public function export(Report $report): string;
}
class ExportService
{
/** @param iterable<ReportExporter> $exporters */
public function __construct(private iterable $exporters) {}
public function export(Report $report, string $format): string
{
foreach ($this->exporters as $exporter) {
if ($exporter->supports($format)) {
return $exporter->export($report);
}
}
throw new UnsupportedFormat($format);
}
}A new bank format is now a new class registered in the container. The existing exporters, and their tests, are untouched.
When not to bother: two stable branches that have not changed in a year. Build the extension point when the third variant shows up, not on day one.
L: Liskov Substitution Principle
If code works with a base type, it must keep working with any subtype without surprises. A subclass that throws "not supported", silently ignores input, or tightens what it accepts breaks that promise.
The production problem
Imagine a PaymentGateway interface with charge() and refund(). A new cash-on-delivery gateway implements refund() by throwing an exception. Every screen that calls refund() on "any gateway" now has a hidden crash path that only appears in production for COD orders.
// Better: model the capability honestly
interface PaymentGateway { public function charge(Money $amount): Charge; }
interface RefundableGateway extends PaymentGateway
{
public function refund(Charge $charge): Refund;
}
if ($gateway instanceof RefundableGateway) {
$gateway->refund($charge);
} else {
$this->queueManualRefund($charge); // explicit, visible business path
}The same idea applies in TypeScript. A React component that accepts a Props type should not require extra, undeclared props from some callers. If a variant needs different inputs, it is a different component or a discriminated union, not a "special" subtype.
I: Interface Segregation Principle
Clients should not be forced to depend on methods they do not use. Small, focused interfaces beat one large "manager" contract.
The production problem
A UserRepository grows to 25 methods: search, reporting queries, password resets, admin bulk operations. A small job that only needs findById() now depends on the whole thing, so every test needs a giant fake and every change to reporting queries touches the job's dependency.
// React: ask only for what the component needs
type Props = { name: string; avatarUrl?: string };
export function UserBadge({ name, avatarUrl }: Props) {
return (
<span className="badge">
{avatarUrl && <img src={avatarUrl} alt="" />}
{name}
</span>
);
}
// Instead of: function UserBadge({ user }: { user: FullUserRecord })The badge can now be reused for customers, vendors or employees, and tests pass two strings instead of building a full user object. On the backend, the same move is splitting a fat repository into a UserLookup for reads and a separate reporting query class.
D: Dependency Inversion Principle
High-level business code should depend on abstractions, not on concrete details like vendor SDKs, HTTP clients or a specific database driver. The details depend on the abstraction instead.

The production problem
When OrderService creates the payment SDK client itself, you cannot test it without network calls, and switching providers means editing business logic. In Laravel, binding an interface in a service provider fixes both:
// AppServiceProvider::register()
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// OrderService only knows the contract
class OrderService
{
public function __construct(private PaymentGateway $gateway) {}
}
// In tests
$this->app->instance(PaymentGateway::class, new FakeGateway());When not to bother: wrapping Laravel's own Cache or Eloquent behind an interface "just in case" usually adds files without adding value. Invert dependencies at the edges that are likely to change or are painful to test: payments, SMS, email providers, AI APIs and external ERPs.
How to Apply SOLID Without Overengineering
The fastest way to make SOLID look bad is to apply all five principles to every class on day one. A few habits keep it practical:
- Wait for the second or third change. Pain points show you where the seams belong.
- Refactor toward a principle, not from a template. Start with working code, then split it when a reason to change appears.
- Use tests as your signal. If a unit test needs ten mocks, a class probably has too many responsibilities or dependencies.
- Prefer clarity over cleverness. One readable class beats five indirections nobody can follow. The post on clean code vs clever code covers this trade-off.
- Decide the right unit first. Knowing when to create a new function, class, service or module makes each principle easier to apply in the right place.
Quick Checklist for Your Next Code Review
- Does this class have more than one group of people who would ask to change it? (S)
- Will the next variant require editing a long conditional that already works? (O)
- Does any implementation throw "not supported" or ignore part of its contract? (L)
- Is a consumer depending on a large interface or object when it needs two fields? (I)
- Does business logic create vendor clients or HTTP calls directly? (D)
Conclusion
SOLID is not a checklist to satisfy for its own sake. Each principle answers a specific, recurring production pain: tangled controllers, fragile conditionals, surprising subclasses, bloated dependencies and untestable integrations. Learn to recognise those pains, apply the matching principle when they appear, and leave simple code simple. That is how SOLID makes a codebase easier to change instead of harder to read.
