Writing code that works is only the beginning.
As an application grows, the difficult part isn’t adding another feature. The difficult part is adding that feature without breaking the existing system.
A payment provider changes.
A new notification channel is introduced.
A business rule becomes more complicated.
A report needs another output format.
A third-party API is replaced.
Suddenly, a class that started as 100 lines has become 800 lines, filled with if/else statements, external API calls, database queries, and business rules.
This is where SOLID principles become useful.
SOLID is a collection of five object-oriented design principles:
S - Single Responsibility Principle
O - Open/Closed Principle
L - Liskov Substitution Principle
I - Interface Segregation Principle
D - Dependency Inversion Principle
But there is an important misconception:
SOLID is not about creating more classes. It’s about making change safer and cheaper.
In this article, we’ll explore each principle using realistic Laravel and PHP examples.
🎯 What Problem Does SOLID Actually Solve?
Imagine an order-processing system.
Initially, you have:
OrderController
|
v
OrderService
|
+-- Validate order
+-- Calculate price
+-- Charge payment
+-- Generate invoice
+-- Send email
+-- Send SMS
+-- Write audit logThis might work perfectly when the application is small.
But eventually:
Stripe becomes PayPal
Email becomes WhatsApp
Invoice generation changes
Pricing rules become more complicated
Multiple payment gateways are required
Different tenants need different providers
Now every change requires modifying the same large class.
SOLID helps us move toward:
Each component has a clear responsibility and dependencies can be replaced without rewriting the business logic.
1. S - Single Responsibility Principle
A class should have one reason to change.
SRP doesn’t mean a class can have only one method.
It means a class should have one cohesive responsibility.
If a class handles payment, invoices, notifications, validation, and reporting, it has too many reasons to change.
👉 Before: One class doing everything
Consider an order-processing service:
class OrderService
{
public function process(Order $order): void
{
// Validate order
if ($order->items->isEmpty()) {
throw new Exception('Order has no items.');
}
// Calculate total
$total = $order->items->sum(
fn ($item) => $item->price * $item->quantity
);
// Charge payment
$payment = Http::post(
'https://payment.example.com/charge',
[
'amount' => $total,
'currency' => 'USD',
'customer_id' => $order->customer_id,
]
);
if (!$payment->successful()) {
throw new Exception('Payment failed.');
}
// Create invoice
Invoice::create([
'order_id' => $order->id,
'amount' => $total,
]);
// Send email
Mail::to($order->customer->email)
->send(new OrderConfirmationMail($order));
// Write audit log
Log::info('Order processed', [
'order_id' => $order->id,
'amount' => $total,
]);
}
}This class is responsible for:
validation
pricing
payment
invoice generation
email
logging
That’s too much.
Why is this a problem?
Suppose the payment provider changes.
You modify OrderService.
Suppose invoice generation changes.
You modify OrderService again.
Suppose the notification system changes.
Again, OrderService.
The class becomes a change hotspot.
The bigger it becomes, the higher the risk that a small change breaks unrelated functionality.
👉 After: Separate responsibilities
Extract cohesive responsibilities.
Order validator
class OrderValidator
{
public function validate(Order $order): void
{
if ($order->items->isEmpty()) {
throw new Exception('Order has no items.');
}
}
}Price calculator
class OrderTotalCalculator
{
public function calculate(Order $order): float
{
return $order->items->sum(
fn ($item) => $item->price * $item->quantity
);
}
}Invoice service
class InvoiceService
{
public function create(
Order $order,
float $amount
): Invoice {
return Invoice::create([
'order_id' => $order->id,
'amount' => $amount,
]);
}
}Notification service
class OrderNotificationService
{
public function sendConfirmation(
Order $order
): void {
Mail::to($order->customer->email)
->send(new OrderConfirmationMail($order));
}
}Now OrderService coordinates the workflow:
class OrderService
{
public function __construct(
private OrderValidator $validator,
private OrderTotalCalculator $calculator,
private PaymentService $paymentService,
private InvoiceService $invoiceService,
private OrderNotificationService $notificationService,
) {}
public function process(Order $order): void
{
$this->validator->validate($order);
$total = $this->calculator->calculate($order);
$this->paymentService->charge(
$order,
$total
);
$this->invoiceService->create(
$order,
$total
);
$this->notificationService
->sendConfirmation($order);
}
}Now each class has a clearer responsibility.
A practical SRP rule
When reviewing a class, ask:
“How many different reasons could cause this class to change?”
If the answer is five, the class probably has five responsibilities.
2. O - Open/Closed Principle
Software should be open for extension but closed for modification.
This means we should be able to introduce new behavior without repeatedly modifying stable business logic.
Payment processing is a good example.
Imagine your application supports multiple payment providers.
👉 Before: Growing conditional logic
class PaymentService
{
public function charge(
string $gateway,
float $amount
): void {
if ($gateway === 'stripe') {
Http::post(
'https://stripe.example.com/charge',
[
'amount' => $amount,
]
);
} elseif ($gateway === 'paypal') {
Http::post(
'https://paypal.example.com/payment',
[
'amount' => $amount,
]
);
} elseif ($gateway === 'razorpay') {
Http::post(
'https://razorpay.example.com/payment',
[
'amount' => $amount,
]
);
}
}
}Today you have three providers.
Tomorrow:
Stripe
PayPal
Razorpay
Adyen
Authorize.Net
Apple Pay
Google Pay
...The if/else chain keeps growing.
Every new provider requires modifying PaymentService.
That’s exactly the kind of change OCP tries to avoid.
👉 After: Use an abstraction
Define a common contract:
interface PaymentGateway
{
public function charge(
float $amount
): PaymentResult;
}Stripe:
class StripeGateway implements PaymentGateway
{
public function charge(
float $amount
): PaymentResult {
// Stripe-specific implementation
return PaymentResult::success();
}
}PayPal:
class PayPalGateway implements PaymentGateway
{
public function charge(
float $amount
): PaymentResult {
// PayPal-specific implementation
return PaymentResult::success();
}
}Now the service depends on the abstraction:
class PaymentService
{
public function __construct(
private PaymentGateway $gateway
) {}
public function charge(
float $amount
): PaymentResult {
return $this->gateway->charge($amount);
}
}Adding another provider doesn’t require modifying PaymentService.
class AdyenGateway implements PaymentGateway
{
public function charge(
float $amount
): PaymentResult {
// Adyen implementation
}
}That’s the practical idea behind OCP.
Laravel makes this easier
Laravel’s service container can bind the interface:
$this->app->bind(
PaymentGateway::class,
StripeGateway::class
);Now Laravel can inject the correct implementation automatically.
3. L - Liskov Substitution Principle
If a class claims to implement an abstraction, it should be safely usable wherever that abstraction is expected.
LSP is essentially about behavioral compatibility.
Inheritance or interface implementation isn’t enough.
The implementation must actually honor the contract.
Consider a reporting system.
👉 Before: Incorrect abstraction
abstract class ReportGenerator
{
abstract public function generate(
array $data
): string;
public function exportToPdf(
string $content
): string {
return "PDF: " . $content;
}
}Now we create a CSV report:
class CsvReportGenerator extends ReportGenerator
{
public function generate(
array $data
): string {
return 'CSV report';
}
public function exportToPdf(
string $content
): string {
throw new Exception(
'CSV cannot be exported to PDF.'
);
}
}Now this code is dangerous:
function generateReport(
ReportGenerator $report
): string {
$content = $report->generate([]);
return $report->exportToPdf($content);
}A caller expects ReportGenerator to support PDF export.
But CsvReportGenerator doesn’t.
The subclass cannot safely substitute the parent abstraction.
That’s an LSP violation.
👉 After: Model capabilities correctly
Separate report generation from PDF export.
interface ReportGenerator
{
public function generate(
array $data
): string;
}interface PdfExporter
{
public function exportToPdf(
string $content
): string;
}A PDF-capable report can implement both:
class SalesReportGenerator
implements ReportGenerator, PdfExporter
{
public function generate(
array $data
): string {
return 'Sales report';
}
public function exportToPdf(
string $content
): string {
return "PDF: " . $content;
}
}A CSV report only implements what it actually supports:
class CustomerCsvReportGenerator
implements ReportGenerator
{
public function generate(
array $data
): string {
return 'Customer CSV report';
}
}Now the abstraction accurately represents the capabilities of the implementation.
A useful LSP smell
If an implementation frequently contains:
throw new Exception('Not supported');for methods it inherited or implemented, stop and reconsider the abstraction.
You may have an LSP or interface-design problem.
👉 Composition Over Inheritance
LSP also leads to an important architectural lesson:
Prefer composition when behavior needs to vary independently.
Instead of building complicated inheritance trees:
Payment
|
+-- OnlinePayment
|
+-- CreditCardPayment
|
+-- StripeCreditCardPaymentyou can compose behavior:
PaymentService
|
+-- PaymentGateway
+-- PaymentValidator
+-- PaymentLoggerComposition often makes business systems easier to evolve because behavior can be replaced independently.
4. I - Interface Segregation Principle
Clients should not be forced to depend on methods they don’t use.
In other words:
Prefer small, focused interfaces over large, “do everything” interfaces.
Consider a notification system.
👉 Before: A fat interface
interface NotificationService
{
public function sendEmail(
string $to,
string $message
): void;
public function sendSms(
string $phone,
string $message
): void;
public function sendPush(
string $deviceToken,
string $message
): void;
public function sendSlack(
string $channel,
string $message
): void;
}Now an SMS implementation must implement everything:
class SmsNotification implements NotificationService
{
public function sendEmail(
string $to,
string $message
): void {
throw new Exception('Not supported');
}
public function sendSms(
string $phone,
string $message
): void {
// Send SMS
}
public function sendPush(
string $deviceToken,
string $message
): void {
throw new Exception('Not supported');
}
public function sendSlack(
string $channel,
string $message
): void {
throw new Exception('Not supported');
}
}This interface is too large.
👉 After: Smaller interfaces
interface EmailNotifier
{
public function sendEmail(
string $to,
string $message
): void;
}interface SmsNotifier
{
public function sendSms(
string $phone,
string $message
): void;
}interface PushNotifier
{
public function sendPush(
string $deviceToken,
string $message
): void;
}Now an SMS provider only needs to implement:
class TwilioSmsNotifier implements SmsNotifier
{
public function sendSms(
string $phone,
string $message
): void {
// Send SMS
}
}This reduces coupling and makes implementations easier to understand.
A practical question
Don’t ask:
“How can I create one interface for everything?”
Ask:
“What capability does this consumer actually need?”
5. D - Dependency Inversion Principle
High-level business logic should not depend directly on low-level implementation details. Both should depend on abstractions.
This is one of the most valuable SOLID principles for Laravel applications.
Consider an order service that directly creates a Stripe client.
👉 Before: Business logic coupled to Stripe
class OrderService
{
public function process(Order $order): void
{
$stripe = new \Stripe\StripeClient(
config('services.stripe.secret')
);
$stripe->paymentIntents->create([
'amount' => $order->total * 100,
'currency' => 'usd',
]);
$order->update([
'status' => 'paid',
]);
}
}Now OrderService knows:
Stripe exists
Stripe’s SDK exists
how Stripe authentication works
how Stripe represents payments
If the business moves from Stripe to another provider, the business service must change.
👉 After: Depend on an abstraction
Create a contract:
interface PaymentGateway
{
public function charge(
float $amount,
string $currency
): PaymentResult;
}Stripe becomes an implementation detail:
class StripePaymentGateway
implements PaymentGateway
{
public function __construct(
private \Stripe\StripeClient $stripe
) {}
public function charge(
float $amount,
string $currency
): PaymentResult {
$payment = $this->stripe
->paymentIntents
->create([
'amount' => (int) ($amount * 100),
'currency' => $currency,
]);
return PaymentResult::success(
$payment->id
);
}
}The business service now depends only on the contract:
class OrderService
{
public function __construct(
private PaymentGateway $paymentGateway
) {}
public function process(Order $order): void
{
$result = $this->paymentGateway->charge(
$order->total,
'USD'
);
if (!$result->successful()) {
throw new PaymentFailedException();
}
$order->update([
'status' => 'paid',
]);
}
}Laravel connects the abstraction with the implementation:
$this->app->bind(
PaymentGateway::class,
StripePaymentGateway::class
);The architecture becomes:
The important part:
OrderService doesn’t depend on Stripe. It depends on the concept of a payment gateway.
⚠️ Code Smells That Often Point Toward SOLID
You don’t need to memorize every SOLID definition.
Instead, learn to recognize the symptoms.
🛑 When NOT to Over-Engineer
Before introducing an abstraction, ask:
1. Is this behavior actually expected to vary?
If not, keep it simple.
2. Is there a real architectural boundary?
External payment providers, messaging providers, storage systems, and third-party APIs often justify abstractions.
3. Does the abstraction make testing easier?
If yes, that’s a strong reason.
4. Is the code difficult to change today?
SOLID is especially valuable around areas that change frequently.
5. Are you solving a real problem?
Don’t build an elaborate architecture for a hypothetical requirement that may never happen.
✨ Final Takeaway
SOLID isn’t a checklist you apply mechanically.
It’s a set of design heuristics for managing change.
The goal isn’t to create the most abstract architecture possible.
The goal is to create software where:
Business Logic
↓
Stable Abstractions
↓
Replaceable ImplementationsWhen a payment provider changes, you shouldn’t need to rewrite order processing.
When a notification channel changes, you shouldn’t need to rewrite business logic.
When a new report format is introduced, existing report generation shouldn’t break.
And when you write tests, you shouldn’t need to call every external service.
That’s where SOLID provides real value.
Good architecture isn’t about writing more code.
It’s about making change cheaper.
And that’s ultimately what SOLID is about.




