Mastering SOLID Principles in Laravel
SOLID principles in Laravel explained with violation-first examples. See what bad code looks like, then fix it with modern PHP 8.3+ patterns.
SOLID principles get a bad reputation. Not because they're wrong, but because most articles explain them with abstract examples that have nothing to do with real code. You read about squares and rectangles, nod along, and then go back to writing the same fat controllers you always have.
I've been building Laravel applications for over nine years, and I'll be honest: I didn't take SOLID seriously until year three. That's when a codebase I'd built started fighting back. Every feature took twice as long because changing one thing broke two others. The principles clicked once I saw them not as theory, but as solutions to problems I was already experiencing.
I want to try something different here. For each principle, I'll show you code that violates it first. Code you've probably written (I know I have). Then I'll show the fix. The contrast is where the learning happens, not the textbook definition.
If you've already read the companion piece on design patterns in Laravel, you'll notice some overlap. That's intentional. SOLID principles are the "why" behind those patterns. Patterns are the "how."
Single Responsibility Principle (SRP)
A class should have one reason to change. That's it. Not "one method" or "one feature," but one reason. If two different stakeholders could ask you to change the same class for different reasons, that class is doing too much.
Here's a controller I've seen in almost every junior developer's first Laravel project:
class OrderController extends Controller
{
public function store(Request $request): JsonResponse
{
// Validation
$validated = $request->validate([
'items' => 'required|array',
'payment_method' => 'required|string',
]);
// Business logic
$order = Order::create($validated);
$total = 0;
foreach ($validated['items'] as $item) {
$total += $item['price'] * $item['quantity'];
$order->items()->create($item);
}
$order->update(['total' => $total]);
// Payment processing
Stripe::charges()->create([
'amount' => $total * 100,
'currency' => 'eur',
]);
// Notification
Mail::to($order->customer)->send(new OrderConfirmation($order));
// PDF generation
$pdf = Pdf::loadView('invoices.order', compact('order'));
Storage::put("invoices/{$order->id}.pdf", $pdf->output());
return response()->json($order);
}
}
This controller has five reasons to change: validation rules, order creation logic, payment processing, notification delivery, and invoice generation. When the business wants to switch from Stripe to PayPal, you're editing the same file that handles email templates. That's a recipe for accidental breakage.
The fix is extracting each concern into its own class:
class OrderController extends Controller
{
public function store(
StoreOrderRequest $request,
CreateOrder $createOrder,
): JsonResponse {
$order = $createOrder->execute(
OrderData::from($request->validated())
);
return response()->json($order);
}
}
The controller's only job is now handling the HTTP layer. Validation lives in a Form Request. Business logic lives in an Action class. Notifications and invoice generation happen through model observers or event listeners. Each class has exactly one reason to change.
My opinion: SRP is the principle I enforce most aggressively. If a class file scrolls past your screen height, something has gone wrong. In Laravel, Form Requests, Action classes, and events are your primary extraction tools. Use them early, not after the controller hits 300 lines. A good rule of thumb: your controller methods should be 10-15 lines long. If they're longer, you're probably mixing concerns. The payoff comes when you need to reuse that business logic. An Action class can be called from a controller, an Artisan command, a queue job, or an API endpoint. A 200-line controller method can only be called from a route.
Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. In plain English: you should be able to add new behavior without editing existing code.
Here's the violation:
class ReportExporter
{
public function export(Report $report, string $format): string
{
return match ($format) {
'pdf' => $this->exportPdf($report),
'csv' => $this->exportCsv($report),
'xlsx' => $this->exportXlsx($report),
// Every new format means editing this class
};
}
private function exportPdf(Report $report): string { /* ... */ }
private function exportCsv(Report $report): string { /* ... */ }
private function exportXlsx(Report $report): string { /* ... */ }
}
Every time the business needs a new export format (and they will), you open this class and add another case. That means retesting everything, and a typo in the new method could break existing formats.
The fix uses an interface and Laravel's service container:
interface ExportFormat
{
public function export(Report $report): string;
public function supports(string $format): bool;
}
class PdfExport implements ExportFormat
{
public function export(Report $report): string
{
return Pdf::loadView('reports.pdf', compact('report'))->output();
}
public function supports(string $format): bool
{
return $format === 'pdf';
}
}
class CsvExport implements ExportFormat
{
public function export(Report $report): string
{
// CSV generation logic
}
public function supports(string $format): bool
{
return $format === 'csv';
}
}
The exporter now delegates to whatever format matches:
class ReportExporter
{
/**
* @param ExportFormat[] $formats
*/
public function __construct(
private readonly array $formats,
) {}
public function export(Report $report, string $format): string
{
foreach ($this->formats as $exporter) {
if ($exporter->supports($format)) {
return $exporter->export($report);
}
}
throw new UnsupportedFormatException("Format {$format} is not supported.");
}
}
Adding a new export format (JSON, XML, whatever) means creating one new class. You never touch ReportExporter again.
My opinion: OCP is where interfaces really earn their keep. Laravel's container tagging feature is perfect for this pattern. You tag all ExportFormat implementations, resolve them as an array, and inject them into ReportExporter. But don't over-apply it. If you genuinely only have two cases and can't imagine a third, a simple match is fine. OCP matters most for extension points you know will grow. Payment gateways, notification channels, and export formats are classic examples. Authentication methods, validation rules, and discount calculators are others. The pattern is the same every time: extract the varying behavior into implementations of a shared interface.
Liskov Substitution Principle (LSP)
Objects of a subclass should be replaceable with their parent class without breaking the program. If your code type-hints a parent class and a child class makes it explode, you've violated LSP.
Here's a real violation I've encountered in production. Consider a base repository:
class UserRepository
{
public function findById(int $id): User
{
return User::findOrFail($id);
}
public function save(User $user): void
{
$user->save();
}
}
Now someone creates a "read-only" variant for reporting:
class ReadOnlyUserRepository extends UserRepository
{
public function save(User $user): void
{
throw new RuntimeException('Cannot save in read-only mode.');
}
}
This breaks LSP. Any code that receives a UserRepository and calls save() will blow up if it gets the read-only version instead. The subclass has changed the behavior contract of its parent in a way that surprises callers.
The fix is using separate interfaces that accurately describe what each implementation can do:
interface ReadableUserRepository
{
public function findById(int $id): User;
public function findByEmail(string $email): ?User;
}
interface WritableUserRepository extends ReadableUserRepository
{
public function save(User $user): void;
public function delete(int $id): void;
}
class EloquentUserRepository implements WritableUserRepository
{
public function findById(int $id): User
{
return User::findOrFail($id);
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function save(User $user): void
{
$user->save();
}
public function delete(int $id): void
{
User::destroy($id);
}
}
Now your reporting code type-hints ReadableUserRepository, and your admin code type-hints WritableUserRepository. No surprises. No exceptions from methods that pretend to exist but secretly don't work. The type system tells you exactly what each consumer can do.
My opinion: LSP violations are sneaky because they don't cause syntax errors. Your code compiles fine and the tests pass, right up until someone substitutes a subclass you didn't account for. The fix is almost always "use interfaces instead of inheritance." In nine years of Laravel development, I can count on one hand the times class inheritance was the right choice over an interface. When building APIs, this is especially important because API consumers rely on consistent behavior from your endpoints.
Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they don't use. If a class only needs two methods but the interface forces it to implement eight, that interface is too fat.
Here's the violation:
interface CommunicationService
{
public function sendEmail(string $to, string $subject, string $body): void;
public function sendSms(string $number, string $message): void;
public function sendPushNotification(string $deviceToken, string $payload): void;
public function sendSlackMessage(string $channel, string $message): void;
public function scheduleMessage(string $type, Carbon $sendAt): void;
}
Now imagine you're building an SMS verification service. You need sendSms(). But to implement this interface, you're forced to write stubs for email, push, Slack, and scheduling. Five methods you'll never call. That's a sign the interface is trying to do too much.
Split it:
interface EmailSender
{
public function send(string $to, string $subject, string $body): void;
}
interface SmsSender
{
public function send(string $number, string $message): void;
}
interface PushNotifier
{
public function notify(string $deviceToken, string $payload): void;
}
Each class implements only the interfaces it needs:
class TwilioSmsService implements SmsSender
{
public function __construct(
private readonly TwilioClient $client,
) {}
public function send(string $number, string $message): void
{
$this->client->messages->create($number, [
'from' => config('services.twilio.from'),
'body' => $message,
]);
}
}
class SmsVerificationService
{
public function __construct(
private readonly SmsSender $sms,
) {}
public function sendCode(string $number): void
{
$code = random_int(100000, 999999);
cache()->put("verification:{$number}", $code, now()->addMinutes(10));
$this->sms->send($number, "Your verification code is: {$code}");
}
}
SmsVerificationService depends only on SmsSender. It doesn't know or care that email and push notifications exist. That's exactly how it should be.
My opinion: Laravel actually models ISP well in its own codebase. Look at ShouldQueue, ShouldBroadcast, and ShouldBeEncrypted on jobs. Each is a small, focused interface you opt into. When you're designing your own interfaces, follow that same approach. One interface per capability. If you find yourself writing empty method stubs to satisfy an interface, that's your signal to split it. I like to name interfaces after the capability they represent: SmsSender, ExportFormat, Cacheable. The name should describe what the implementing class can do, not what it is.
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. This is the principle that ties everything else together.
Here's the violation:
class OrderNotifier
{
public function __construct(
private readonly MailgunClient $mailer,
) {}
public function notifyCustomer(Order $order): void
{
$this->mailer->send(
to: $order->customer->email,
subject: 'Order Confirmed',
body: "Your order #{$order->id} has been confirmed.",
);
}
}
OrderNotifier (high-level business logic) depends directly on MailgunClient (low-level infrastructure). If you switch to Postmark or want to use a fake mailer in tests, you're rewriting the notifier class. The business rule ("notify customer when order is placed") is tangled up with the implementation detail ("send via Mailgun").
The fix introduces an abstraction between them:
interface OrderNotificationChannel
{
public function send(Order $order, string $message): void;
}
class EmailNotificationChannel implements OrderNotificationChannel
{
public function __construct(
private readonly Mailer $mailer,
) {}
public function send(Order $order, string $message): void
{
$this->mailer->to($order->customer->email)
->send(new OrderNotification($order, $message));
}
}
class OrderNotifier
{
public function __construct(
private readonly OrderNotificationChannel $channel,
) {}
public function notifyCustomer(Order $order): void
{
$this->channel->send($order, "Your order #{$order->id} has been confirmed.");
}
}
Now bind it in your service provider:
// AppServiceProvider.php
public function register(): void
{
$this->app->bind(
OrderNotificationChannel::class,
EmailNotificationChannel::class,
);
}
Laravel's contextual bindings take this even further. Need different notification channels for different contexts?
$this->app->when(OrderNotifier::class)
->needs(OrderNotificationChannel::class)
->give(EmailNotificationChannel::class);
$this->app->when(InventoryAlertService::class)
->needs(OrderNotificationChannel::class)
->give(SlackNotificationChannel::class);
Same interface, different implementations, zero conditionals in your business logic. This is DIP at its best, and Laravel's container makes it practically effortless.
My opinion: DIP is the principle that makes everything else work. SRP tells you to extract classes, OCP tells you to use interfaces for extension points, ISP tells you to keep those interfaces small, and DIP tells you to depend on the interfaces rather than the implementations. Once you internalize this, the service container stops feeling like a mysterious black box and starts feeling like the most powerful tool in Laravel. Contextual bindings are the feature that makes DIP truly effortless in Laravel. Instead of writing factory logic to decide which implementation to use, you declare it once in the provider and forget about it. If you're processing background work through queue jobs, DIP is what lets you swap Redis for SQS without touching a single job class.
How SOLID Principles Work Together
These principles aren't independent checkboxes you tick off during code review. They reinforce each other, and the real benefit shows up when you apply them together in a single feature.
Here's how a well-structured feature looks when all five principles cooperate. Imagine building a SaaS application feature for processing subscription renewals.
SRP means the RenewSubscription action class handles only renewal logic, not payment processing or notification delivery. OCP means you can add new payment providers without modifying the renewal logic. LSP means any PaymentGateway implementation works interchangeably, whether it's Stripe, PayPal, or a test double that always succeeds. ISP means the gateway interface only includes charge() and refund(), not twenty methods for every possible payment operation. And DIP means the renewal action depends on the PaymentGateway interface, not on StripeClient directly.
The result is code where each piece is small, focused, and replaceable. You can test the renewal logic with a fake gateway, swap Stripe for PayPal by changing one binding, and add Slack notifications without touching the payment code. That's not academic perfection for its own sake. That's practical maintainability that saves you hours when the inevitable "can we also support..." request lands in your inbox.
FAQ
Do I need to apply all five SOLID principles to every class?
No. SOLID principles are guidelines, not laws. Apply them where they reduce complexity. A simple Eloquent model with three accessors doesn't need an interface or an abstraction layer. The principles become valuable as your application grows beyond simple CRUD and you start feeling friction when making changes.
What's the most common SOLID violation in Laravel applications?
SRP violations in controllers, by a wide margin. The "god controller" with validation, business logic, third-party API calls, and email sending crammed into a single store() method is the most frequent pattern I see when reviewing Laravel codebases. Form Requests and Action classes fix this quickly.
How do SOLID principles relate to design patterns?
SOLID principles explain the "why" behind good architecture, while design patterns provide the "how." The Strategy pattern exists because of OCP and DIP. The Repository pattern implements ISP and SRP. Once you understand SOLID, design patterns feel less like memorized recipes and more like natural consequences of good principles.
Does following SOLID principles make code harder to read?
It can, if taken to extremes. Over-abstracting a simple feature with five interfaces and three layers of indirection is worse than a straightforward implementation. The goal is to reduce complexity, not redistribute it across more files. If a new team member needs a map to understand your code, you've gone too far. If you're working with data transformations, a JSON to PHP array converter can save time when scaffolding DTOs and test data.
Should I refactor existing code to follow SOLID principles?
Refactor incrementally, not all at once. When you're fixing a bug or adding a feature to an existing class, improve the design of the code you touch. Extract a bloated controller method into an Action. Split a fat interface. Replace a concrete dependency with an interface binding. Over time, the codebase improves without the risk of a massive rewrite.
Wrapping Up
SOLID principles aren't about writing perfect code on the first try. They're about recognizing structural problems as they emerge and having a toolkit to fix them. The fat controller is an SRP violation. The growing switch statement is an OCP violation. The subclass that throws "not implemented" is an LSP violation. The interface with eight methods that most implementers stub out is an ISP violation. And the class that instantiates its own dependencies with new is a DIP violation.
You don't need to memorize acronyms. You need to recognize the pain: "Every time I change X, Y breaks." That pain is a SOLID violation telling you where the abstraction boundary should be. Start by fixing the violations in the code you're already working on, and let the principles guide your decisions when writing new features.
If you're refactoring a Laravel application and want a second opinion on your architecture, let's talk about it.
About Hafiz
Senior Full-Stack Developer with 9+ years building web apps and SaaS platforms. I specialize in Laravel and Vue.js, and I write about the real decisions behind shipping production software.
View My Work →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Mastering Design Patterns in Laravel
A practical guide to the design patterns that actually matter in Laravel, with m...
Laravel API Development: Production-Ready Best Practices You Can't Ignore
Master Laravel API development with production-tested patterns for authenticatio...
The Ralph Wiggum Technique: Let Claude Code Work Through Your Entire Task List While You Sleep
Queue up your Laravel task list, walk away, and come back to finished work. Here...