Why SOLID Matters (When Applied Pragmatically)
SOLID principles are not rules carved in stone. They're guidelines that, when applied thoughtfully, make your code easier to understand, test, and change. The key word is thoughtfully — blind adherence to SOLID can produce code that's more complex than the problem it solves.
Let's see what each principle looks like in a real Laravel application.
S — Single Responsibility Principle
"A class should have only one reason to change."
This doesn't mean "a class should only do one thing." It means a class should be responsible to one actor or stakeholder.
Before: A controller doing too much
class OrderController extends Controller
{
public function store(Request $request)
{
// Validation
$validated = $request->validate([
'items' => 'required|array',
'payment_method' => 'required|string',
]);
// Business logic: calculate totals
$subtotal = 0;
foreach ($validated['items'] as $item) {
$product = Product::findOrFail($item['id']);
$subtotal += $product->price * $item['quantity'];
}
$tax = $subtotal * 0.1;
$total = $subtotal + $tax;
// Persistence
$order = Order::create([
'user_id' => auth()->id(),
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
]);
// Payment processing
Stripe::charges()->create([
'amount' => $total * 100,
'currency' => 'usd',
'source' => $validated['payment_method'],
]);
// Notification
Mail::to(auth()->user())->send(new OrderConfirmation($order));
return redirect()->route('orders.show', $order);
}
}
After: Each concern in its own place
class OrderController extends Controller
{
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
$order = $placeOrder->execute(
PlaceOrderData::fromRequest($request)
);
return redirect()->route('orders.show', $order);
}
}
class PlaceOrder
{
public function __construct(
private readonly PaymentGateway $payments,
) {}
public function execute(PlaceOrderData $data): Order
{
$order = Order::create($data->toArray());
$this->payments->charge($order->total, $data->paymentMethod);
event(new OrderPlaced($order));
return $order;
}
}
// Side effects handled by event listeners
// OrderPlaced → SendConfirmationEmail
// OrderPlaced → UpdateInventory
// OrderPlaced → NotifySalesTeam
O — Open/Closed Principle
"Open for extension, closed for modification." In practice, this means you can add new behavior without changing existing code.
// Instead of a growing switch statement...
class NotificationSender
{
public function send(User $user, string $message, string $channel)
{
match($channel) {
'email' => $this->sendEmail($user, $message),
'sms' => $this->sendSms($user, $message),
'slack' => $this->sendSlack($user, $message),
// Adding a new channel = modifying this class
};
}
}
// ...use an interface and let Laravel's container resolve it
interface NotificationChannel
{
public function send(User $user, string $message): void;
}
class EmailChannel implements NotificationChannel { /* ... */ }
class SmsChannel implements NotificationChannel { /* ... */ }
class SlackChannel implements NotificationChannel { /* ... */ }
// Register in a service provider
$this->app->tag([EmailChannel::class, SmsChannel::class, SlackChannel::class], 'notification.channels');
// Adding Telegram? Just create a new class and tag it. No existing code changes.
class TelegramChannel implements NotificationChannel { /* ... */ }
L — Liskov Substitution Principle
"Subtypes must be substitutable for their base types." If your code expects a Logger, any implementation of Logger should work without breaking things.
// Violation: CachedRepository changes behavior in unexpected ways
class CachedProductRepository extends ProductRepository
{
public function find(int $id): ?Product
{
return Cache::remember("product.{$id}", 3600, function () use ($id) {
return parent::find($id);
});
}
public function save(Product $product): void
{
parent::save($product);
// But what if callers expect find() to return the latest data
// immediately after save()? The cache might serve stale data.
// This violates LSP.
}
}
// Better: make caching explicit and invalidate properly
class CachedProductRepository extends ProductRepository
{
public function save(Product $product): void
{
parent::save($product);
Cache::forget("product.{$product->id}");
}
}
I — Interface Segregation Principle
"No client should be forced to depend on methods it doesn't use."
// Too broad: not every export needs all these methods
interface Exportable
{
public function toCsv(): string;
public function toPdf(): string;
public function toExcel(): string;
public function toJson(): string;
}
// Better: small, focused interfaces
interface CsvExportable
{
public function toCsv(): string;
}
interface PdfExportable
{
public function toPdf(): string;
}
// Classes implement only what they need
class InvoiceReport implements CsvExportable, PdfExportable
{
public function toCsv(): string { /* ... */ }
public function toPdf(): string { /* ... */ }
}
class AuditLog implements CsvExportable
{
public function toCsv(): string { /* ... */ }
// No need to implement PDF — audit logs are CSV-only
}
D — Dependency Inversion Principle
"Depend on abstractions, not concretions." This is where Laravel's service container shines.
// Tightly coupled to a specific implementation
class OrderService
{
public function sendConfirmation(Order $order): void
{
$mailer = new SendGridMailer(); // Hard dependency
$mailer->send($order->user->email, 'Your order is confirmed');
}
}
// Inverted: depend on the interface
class OrderService
{
public function __construct(
private readonly Mailer $mailer // Interface, not concrete class
) {}
public function sendConfirmation(Order $order): void
{
$this->mailer->send($order->user->email, 'Your order is confirmed');
}
}
// Bind in AppServiceProvider
$this->app->bind(Mailer::class, SendGridMailer::class);
// In tests, swap for a fake
$this->app->bind(Mailer::class, FakeMailer::class);
The Pragmatic Approach
Don't apply SOLID principles preemptively. Apply them when you feel the pain of not having them — when a class is hard to test, when adding a feature requires changing five files, when a bug fix in one place breaks something else.
- SRP: Refactor when a class becomes hard to name or test
- OCP: Introduce interfaces when you have 3+ implementations of the same concept
- LSP: Watch for subclasses that throw unexpected exceptions or change return types
- ISP: Split interfaces when implementations start returning
nullor throwing "not supported" - DIP: Inject dependencies when you need to swap implementations (testing, multi-provider, etc.)