not theory
Most explanations of SOLID principles work with shapes, ducks, or animals, and leave open what that looks like in real application code. This article instead uses a single, connected order-processing pipeline in PHP 8.4, in which each of the five principles solves a concrete, traceable maintainability problem: an Order class that does too much, discount rules that grow into an if/else cascade, a payment gateway that breaks its own contract, an overly fat repository interface, and a notification service too tightly coupled to a concrete mailer.
Table of Contents
- 1. Why SOLID principles are a maintainability tool, not an academic concept
- 2. Single Responsibility Principle: an Order class that does too much
- 3. Open/Closed Principle: discount rules without growing if/else cascades
- 4. Liskov Substitution Principle: when a payment gateway breaks its contract
- 5. Interface Segregation Principle: splitting an overly fat repository interface
- 6. Dependency Inversion Principle: the notification service and the abstraction
- 7. SOLID principles working together: the entire order-processing pipeline
- 8. When SOLID is overkill: drawing pragmatic boundaries
- 9. Violation vs. SOLID-compliant solution in direct comparison
- 10. Summary
- 11. FAQ
1. Why SOLID principles are a maintainability tool, not an academic concept
The SOLID principles have long been treated as exam material: five letters, five definitions, memorized and rarely recognized in real code. That is mostly because the classic explanations work with shapes, ducks, or birds, a Rectangle that inherits from Square, or a Bird that cannot fly. These examples are didactically convenient, but they do not show what a violation of the SOLID principles actually feels like in a real application: a class that has to be touched on every requirement, a test case that needs three database mocks to check a single method, or a bugfix that fixes one thing in one place and breaks three others.
This article therefore completely avoids shapes and animals. Instead, a single, connected order-processing pipeline accompanies the entire text: an Order class, discount rules, payment gateways, repositories, and a notification service. Each of the five SOLID principles, Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion, solves a concrete problem in it that inevitably arises as a real application grows. The code is PHP 8.4 with strict_types, constructor property promotion, and readonly properties, framework-agnostic and without reference to any particular stack.
One thing to note upfront: the SOLID principles are not an end in themselves. They are a toolbox for reducing coupling and clearly separating responsibilities, wherever an application actually grows and changes. Where that growth does not occur, strictly following all five principles can lead to unnecessary indirection, a point section 8 addresses directly. The SOLID principles are a compass for design decisions, not a checklist to work through blindly.
2. Single Responsibility Principle: an Order class that does too much
The Single Responsibility Principle, the first of the five SOLID principles, states that a class should have exactly one reason to change. In practice, almost every order-processing application starts with an Order class that fails to do exactly that: it calculates totals, validates input, persists itself to the database, and at the end sends a shipping confirmation email. Four entirely different reasons for change, bundled into a single class. If the email template changes, the Order class has to be touched. If the database schema changes, likewise. If the validation rule for minimum order value changes, likewise.
The violation of the SOLID principles does not show up here as a syntax error, but as friction: a unit test for the total calculation has to mock a database connection, even though it has nothing to do with persistence. A developer who only needs to adjust the shipping email inevitably reads through validation logic that does not concern them. The solution following the Single Responsibility Principle splits the four responsibilities into four classes: Order as a pure data object, OrderValidator for business rules, OrderRepository for persistence, and OrderShippedNotifier for the notification. Every class now has exactly one reason to change, and that is exactly the core of the principle.
declare(strict_types=1);
// BEFORE: one class doing persistence, validation and notification
final class Order
{
private array $items = [];
public function __construct(
private readonly int $customerId,
) {}
public function addItem(string $sku, int $quantity, string $currency, int $unitPriceMinor): void
{
$this->items[] = compact('sku', 'quantity', 'currency', 'unitPriceMinor');
}
public function total(): int
{
return array_sum(array_map(
static fn (array $item): int => $item['unitPriceMinor'] * $item['quantity'],
$this->items,
));
}
// Validation logic mixed into the data class
public function validate(): void
{
if ($this->items === []) {
throw new \DomainException('Order must contain at least one item.');
}
}
// Persistence mixed into the data class
public function save(\PDO $connection): void
{
$connection->prepare('INSERT INTO orders (customer_id, total) VALUES (?, ?)')
->execute([$this->customerId, $this->total()]);
}
// Notification mixed into the data class
public function sendShippedMail(\Closure $mailer): void
{
$mailer("Your order for customer {$this->customerId} has shipped.");
}
}
3. Open/Closed Principle: discount rules without growing if/else cascades
The Open/Closed Principle requires that a class be open for extension but closed for modification. A classic violation of the SOLID principles arises almost automatically with discount rules: the first discount, a flat percentage for new customers, gets written as a single if inside a calculateDiscount method. The second discount, a bulk discount from ten items on, is added as an elseif. After a year, the method has twelve branches, every new marketing campaign requires a change to exactly this method, and every change risks accidentally breaking one of the existing eleven rules.
The SOLID principles-compliant solution replaces the if/else cascade with a DiscountRule interface with a single method, for example apply(Order $order): int. Every concrete discount rule, NewCustomerDiscount, BulkQuantityDiscount, SeasonalDiscount, implements this interface as its own, small class. The calling code iterates over a list of rules and sums the results, without knowing how many rules exist or what they do in detail. A new discount campaign means: add a new class, do not touch any existing method. That is exactly what "open for extension, closed for modification" means.
declare(strict_types=1);
interface DiscountRule
{
// Returns the discount amount in minor currency units (e.g. cents)
public function apply(Order $order): int;
}
final readonly class NewCustomerDiscount implements DiscountRule
{
public function __construct(private bool $isNewCustomer, private int $flatDiscountMinor) {}
public function apply(Order $order): int
{
return $this->isNewCustomer ? $this->flatDiscountMinor : 0;
}
}
final readonly class BulkQuantityDiscount implements DiscountRule
{
public function __construct(private int $thresholdQuantity, private float $percentOff) {}
public function apply(Order $order): int
{
if ($order->totalQuantity() < $this->thresholdQuantity) {
return 0;
}
return (int) round($order->total() * $this->percentOff);
}
}
// Adding a new promotion never touches this class again
final readonly class DiscountEngine
{
/** @param DiscountRule[] $rules */
public function __construct(private array $rules) {}
public function totalDiscount(Order $order): int
{
return array_sum(array_map(
static fn (DiscountRule $rule): int => $rule->apply($order),
$this->rules,
));
}
}
4. Liskov Substitution Principle: when a payment gateway breaks its contract
The Liskov Substitution Principle is the most frequently misunderstood among the five SOLID principles: it is not just about a subclass implementing the same method signature, but about it honoring the same contract that the base class promises. A typical example in an order-processing pipeline: an abstract PaymentGateway with a method charge(int $amountMinor): PaymentResult, which promises for all implementations to either return a successful result or throw a PaymentDeclinedException. A subclass LegacyWireTransferGateway violates this contract if it instead returns null on a decline, or throws a completely different exception the calling code does not expect.
The effect is insidious, because the violation of the SOLID principles does not show up at compile time, but only at runtime, usually exactly when the legacy gateway instance gets used and nobody expects it anymore. The calling code, which expects a PaymentGateway, can no longer blindly rely on the contract and has to build special cases for individual subclasses, exactly the kind of coupling polymorphism is meant to avoid. The correct solution: the base class or interface must explicitly and completely fix the contract, whether through the type system, an exception hierarchy, or documented behavior, and every subclass must honor it exactly, without claiming special cases for itself.
declare(strict_types=1);
final class PaymentDeclinedException extends \RuntimeException {}
interface PaymentGateway
{
// Contract: returns a successful PaymentResult or throws PaymentDeclinedException.
// Must never return null and must never throw any other exception type.
public function charge(int $amountMinor): PaymentResult;
}
final readonly class PaymentResult
{
public function __construct(
public string $transactionId,
public int $chargedAmountMinor,
) {}
}
final readonly class CreditCardGateway implements PaymentGateway
{
public function __construct(private CardProcessorClient $client) {}
public function charge(int $amountMinor): PaymentResult
{
$response = $this->client->submit($amountMinor);
if (!$response->approved) {
throw new PaymentDeclinedException('Card declined by processor.');
}
return new PaymentResult($response->transactionId, $amountMinor);
}
}
// FIXED: honors the exact same contract as CreditCardGateway, no null, no custom exception
final readonly class LegacyWireTransferGateway implements PaymentGateway
{
public function __construct(private WireTransferClient $client) {}
public function charge(int $amountMinor): PaymentResult
{
$status = $this->client->initiateTransfer($amountMinor);
if ($status === WireTransferClient::STATUS_REJECTED) {
throw new PaymentDeclinedException('Wire transfer rejected by bank.');
}
return new PaymentResult($status->reference, $amountMinor);
}
}
5. Interface Segregation Principle: splitting an overly fat repository interface
The Interface Segregation Principle requires that clients not be forced to depend on methods they do not use. In the order-processing pipeline, the typical violation of the SOLID principles arises from a single, well-intentioned OrderRepositoryInterface that grows over time: save(), findById(), findByCustomer(), delete(), archiveOlderThan(), exportToCsv(). A ReportingService that only wants to read orders has to build a test double implementation that also provides delete() and archiveOlderThan(), even though it never calls these methods. Every change to the archiving logic forces the reporting code to adjust its mocks, even though it has nothing to do with it functionally.
The SOLID principles-compliant solution splits the fat interface into smaller, role-specific interfaces: OrderReader with findById() and findByCustomer(), OrderWriter with save() and delete(), OrderArchiver with archiveOlderThan(). A concrete DoctrineOrderRepository class can still implement all three interfaces, but the ReportingService now only depends on OrderReader. It no longer even sees archiving-related methods, its test needs no mock for delete(), and changes to archiving no longer touch its code.
declare(strict_types=1);
interface OrderReader
{
public function findById(int $orderId): ?Order;
/** @return Order[] */
public function findByCustomer(int $customerId): array;
}
interface OrderWriter
{
public function save(Order $order): void;
public function delete(int $orderId): void;
}
interface OrderArchiver
{
public function archiveOlderThan(\DateTimeImmutable $cutoff): int;
}
// Reporting only ever depends on the narrow interface it actually needs
final readonly class ReportingService
{
public function __construct(private OrderReader $orders) {}
public function customerTotalSpend(int $customerId): int
{
return array_sum(array_map(
static fn (Order $order): int => $order->total(),
$this->orders->findByCustomer($customerId),
));
}
}
// One concrete class can still implement all three narrow interfaces
final readonly class DoctrineOrderRepository implements OrderReader, OrderWriter, OrderArchiver
{
public function __construct(private \PDO $connection) {}
public function findById(int $orderId): ?Order
{
// Implementation detail omitted for brevity
return null;
}
/** @return Order[] */
public function findByCustomer(int $customerId): array
{
return [];
}
public function save(Order $order): void {}
public function delete(int $orderId): void {}
public function archiveOlderThan(\DateTimeImmutable $cutoff): int
{
return 0;
}
}
6. Dependency Inversion Principle: the notification service and the abstraction
The Dependency Inversion Principle is the last, but perhaps most consequential, of the five SOLID principles: high-level modules should not depend on low-level modules, both should depend on abstractions. A common violation of the SOLID principles in the order-processing pipeline is an OrderShippedNotifier that directly instantiates and calls a concrete SmtpMailer class. As long as only emails are sent, that works. As soon as a customer expects an SMS or a push notification instead, the OrderShippedNotifier itself has to be changed, even though its actual job, deciding when to notify, stays unchanged.
The solution following the Dependency Inversion Principle introduces an abstraction, a NotificationChannel interface with a method send(string $recipient, string $message): void. The OrderShippedNotifier now only depends on this interface, not on SmtpMailer or any particular SMS API. Via constructor property promotion, the concrete implementation is injected from outside, typically through a dependency injection container. A switch from email to SMS means: register a new class implementing NotificationChannel in the container configuration, do not touch a single line inside OrderShippedNotifier. This exact reversal of the dependency direction, from concrete detail to abstraction, is the core of the SOLID principles in this final point.
declare(strict_types=1);
interface NotificationChannel
{
public function send(string $recipient, string $message): void;
}
final readonly class SmtpNotificationChannel implements NotificationChannel
{
public function __construct(private SmtpMailer $mailer) {}
public function send(string $recipient, string $message): void
{
$this->mailer->send($recipient, 'Your order has shipped', $message);
}
}
final readonly class SmsNotificationChannel implements NotificationChannel
{
public function __construct(private SmsGatewayClient $client) {}
public function send(string $recipient, string $message): void
{
$this->client->sendText($recipient, $message);
}
}
// High-level policy depends only on the abstraction, never on a concrete channel
final readonly class OrderShippedNotifier
{
public function __construct(private NotificationChannel $channel) {}
public function notify(Order $order, string $recipient): void
{
$this->channel->send(
$recipient,
"Order #{$order->id()} for customer {$order->customerId()} has shipped.",
);
}
}
7. SOLID principles working together: the entire order-processing pipeline
Viewed individually, the SOLID principles each solve an isolated problem. Their real power only shows once you look at the entire order-processing pipeline as a whole: an OrderProcessor class that validates an Order, applies discounts via the DiscountEngine, charges via a PaymentGateway, persists via an OrderWriter, and notifies via a NotificationChannel, is itself pure orchestration. It knows not a single concrete implementation, only the five abstractions the previous sections introduced.
This orchestration class is a direct result of all five SOLID principles at once: it has exactly one responsibility, steering the flow (SRP). New discount rules or payment gateways get added without touching it (OCP). Every payment gateway implementation can be swapped in, because it honors the same contract (LSP). It depends only on the narrow interfaces it actually needs, not on a fat repository (ISP). And it depends exclusively on abstractions, never on concrete classes (DIP). The result is a class that is testable in isolation, with simple test doubles for each of the five dependencies, with no real database or real mail sending required.
declare(strict_types=1);
// The orchestration class combines all five SOLID principles at once:
// it depends only on abstractions, and has exactly one job: coordinate the flow.
final readonly class OrderProcessor
{
public function __construct(
private OrderValidator $validator,
private DiscountEngine $discounts,
private PaymentGateway $gateway,
private OrderWriter $repository,
private NotificationChannel $channel,
) {}
public function process(Order $order, string $notifyRecipient): PaymentResult
{
$this->validator->validate($order);
$discount = $this->discounts->totalDiscount($order);
$amountDue = $order->total() - $discount;
$result = $this->gateway->charge($amountDue);
$this->repository->save($order);
$this->channel->send(
$notifyRecipient,
"Order #{$order->id()} confirmed, charged {$result->chargedAmountMinor} minor units.",
);
return $result;
}
}
Anyone wanting to test this pipeline injects a test double for each of the five dependencies, a fake PaymentGateway that always charges successfully, a fake NotificationChannel that only collects messages. No test needs a real database connection or a real SMTP server. This exact testability is the practical proof that the SOLID principles actually apply here, not just on paper.
8. When SOLID is overkill: drawing pragmatic boundaries
The SOLID principles can also be overdone, and that happens regularly in codebases that never got past prototype status. An internal script that generates a CSV file once a month does not need a DiscountRule interface with three implementations, if there is exactly one discount rule and it has not changed in two years. An interface with a single implementation that will never get a second one is not an application of the SOLID principles, it is pure indirection with no benefit: more files, more jumps while reading, no added value.
The pragmatic test is: is this piece of code likely to change in several, functionally different directions? For the order-processing pipeline in this article, the answer is yes, new discount campaigns, new payment providers, and new notification channels realistically show up in any growing e-commerce application. For an internal one-off script with no foreseeable variation, the answer is often no. The SOLID principles are a means to reduce the cost of change in code that actually changes. Where that change is not expected, strictly following all five principles only produces extra complexity with no payoff, and pragmatically leaving it out is the right call.
9. Violation vs. SOLID-compliant solution in direct comparison
The following table summarizes all five SOLID principles using the order-processing pipeline: the typical violation, the compliant solution from this article, and the concrete benefit that solution brings in practice.
| Principle | Typical violation | SOLID-compliant solution | Benefit |
|---|---|---|---|
| Single Responsibility | Order validates, persists, and sends mail |
Validator, repository, notifier separated | Isolated, simple tests per responsibility |
| Open/Closed | if/elseif cascade for discount rules | DiscountRule interface per rule |
Add a new rule with no change risk |
| Liskov Substitution | Subclass returns null instead of an exception |
All gateways honor the same contract | Safe substitutability with no special cases |
| Interface Segregation | One fat OrderRepositoryInterface |
Reader, writer, archiver separated | Clients depend only on methods they need |
| Dependency Inversion | Notifier depends directly on SmtpMailer |
NotificationChannel abstraction |
Swap the channel without changing high-level code |
The common denominator across all five rows: the violation tightly couples code to a concrete detail that later changes, while the SOLID principles-compliant solution isolates that change behind an abstraction. This exact isolation is what makes the difference between a five-minute bugfix and a risky change touching three places.
10. Summary
The SOLID principles solve five related but distinct problems on the order-processing pipeline in this article. Single Responsibility splits an overloaded Order class into four focused classes. Open/Closed replaces a growing if/else cascade with swappable DiscountRule implementations. Liskov Substitution ensures that every PaymentGateway honors the same contract, regardless of which concrete implementation is used. Interface Segregation splits a fat repository interface into role-specific interfaces. Dependency Inversion decouples the OrderShippedNotifier from a concrete mailer class in favor of an abstraction.
Working together, the SOLID principles produce an OrderProcessor class that is pure orchestration, testable in isolation, and with no knowledge of concrete implementation details. What still matters is the pragmatic view from section 8: the SOLID principles pay off where code actually keeps evolving in several directions, not everywhere automatically. Anyone who recognizes this difference applies the SOLID principles deliberately, instead of mechanically pressing them onto every class.
SOLID Principles in Practice, the Essentials at a Glance
Single Responsibility
One class, one reason to change. Order, validator, repository, and notifier stay separate.
Open/Closed & Liskov Substitution
New discount rules as their own classes. Every PaymentGateway implementation honors the same contract.
Interface Segregation
Reader, writer, and archiver separated instead of one fat repository interface.
Dependency Inversion
NotificationChannel abstraction instead of direct coupling to SmtpMailer.
11. FAQ: SOLID Principles in Practice
1What are the SOLID principles in one sentence?
2Why aren't shape or animal examples enough?
3What is the core of the Single Responsibility Principle?
4How do you avoid if/else cascades for discount rules?
5What does a Liskov Substitution violation concretely mean?
6How do you recognize an overly fat interface?
7What does Dependency Inversion mean in practice?
8Can you overdo the SOLID principles?
9Do you need all five principles at once?
10Are SOLID principles tied to a framework?
Mironsoft
Object-oriented design, code reviews, and architecture consulting
A codebase with no clear responsibilities and abstractions?
We analyze existing PHP code, identify violations of the SOLID principles, and refactor precisely where it actually pays off, without over-engineering and without unnecessary interfaces.
Architecture review
Analysis of existing class structures for SOLID principle violations and coupling risks
Targeted refactoring
Separating responsibilities, sharpening interfaces, inverting dependencies, pragmatically dosed
Team training
Teaching SOLID principles through real examples from your own code instead of theory