Understanding SOLID Principles in Laravel Development
A deep dive into how SOLID principles can be practically applied to Laravel applications for cleaner, scalable, and testable architectures.
SOLID principles are a set of five design guidelines for writing maintainable, scalable, and testable code in object-oriented programming languages. When developing in modern PHP and Laravel, adhering to SOLID helps prevent architectural rot, eliminates bloated models and controllers, and ensures that systems stay adaptable as business requirements change.
S — Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should fulfill a single focused role within the system.
Bad Example: The "God" Model
In Laravel projects, Eloquent models frequently accumulate responsibilities far beyond representing database state:
Code • php// Bad example: Eloquent model handling formatting, notifications, and export logic namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Mail; use App\Mail\WelcomeMail; class User extends Model { protected $fillable = ['name', 'email', 'billing_address']; public function formatBillingAddress(): string { return strtoupper($this->billing_address); } public function sendWelcomeNotification(): void { Mail::to($this->email)->send(new WelcomeMail($this)); } public function generateMonthlyStatementPdf(): string { // 50 lines of raw PDF generation and binary formatting return "PDF-BINARY-CONTENT"; } }
In this anti-pattern, changes to your PDF templating engine, email delivery vendor, or address formatting rules all force edits to the core User model.
Good Example: Segregated Responsibilities
Code • php// Good example: Model focuses on data persistence; dedicated classes handle specialized tasks namespace App\Models; use Illuminate\Database\Eloquent\Model; class User extends Model { protected $fillable = ['name', 'email', 'billing_address']; } // app/Services/UserNotifier.php namespace App\Services; use App\Models\User; use App\Mail\WelcomeMail; use Illuminate\Support\Facades\Mail; class UserNotifier { public function sendWelcome(User $user): void { Mail::to($user->email)->send(new WelcomeMail($user)); } } // app/Services/StatementPdfGenerator.php namespace App\Services; use App\Models\User; class StatementPdfGenerator { public function generateForUser(User $user): string { // Pure PDF generation logic isolated from the User model return "PDF-BINARY-CONTENT"; } }
Now the User model only cares about user data, while formatting, notifications, and PDF rendering live in independent, testable service classes.
O — Open/Closed Principle (OCP)
Software entities (classes, modules, functions) should be open for extension, but closed for modification. You should be able to introduce new behavior without rewriting existing, tested production code.
Bad Example: Conditionals on Concrete Types
Code • php// Bad example: Modifying the service every time a new gateway is added class PaymentProcessor { public function process(string $gateway, float $amount): bool { if ($gateway === 'stripe') { // Stripe-specific API calls return true; } elseif ($gateway === 'paypal') { // PayPal-specific API calls return true; } elseif ($gateway === 'wasabi_pay') { // Adding a third gateway requires changing this method! return true; } throw new \InvalidArgumentException("Unsupported gateway"); } }
Good Example: Strategy Pattern via Interfaces
Code • php// Good example: Open to new providers, closed to modification namespace App\Contracts; interface PaymentGateway { public function charge(float $amount): bool; } namespace App\Services\Payments; use App\Contracts\PaymentGateway; class StripeGateway implements PaymentGateway { public function charge(float $amount): bool { // Stripe integration logic return true; } } class PayPalGateway implements PaymentGateway { public function charge(float $amount): bool { // PayPal integration logic return true; } } class CheckoutService { public function completePurchase(PaymentGateway $gateway, float $amount): bool { return $gateway->charge($amount); } }
Adding Apple Pay or Google Pay no longer touches existing payment code—you simply implement PaymentGateway and pass the new class.
L — Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program. If class B is a subtype of class A, you should be able to pass B anywhere A is expected without runtime surprises.
Bad Example: Breaking the Base Contract
Code • php// Bad example: Subclass throws unexpected exceptions, violating the base contract interface UserRepositoryInterface { public function findById(int $id): User; public function save(User $user): void; } class ReadOnlyUserRepository implements UserRepositoryInterface { public function findById(int $id): User { return User::findOrFail($id); } public function save(User $user): void { // Violates LSP: Callers expecting a UserRepositoryInterface will crash! throw new \BadMethodCallException("Write operations are disabled on read replicas."); } }
Good Example: Refined Abstractions
Code • php// Good example: Segregate read and write capabilities so contracts remain honest namespace App\Contracts; use App\Models\User; interface ReadableUserRepository { public function findById(int $id): User; } interface WritableUserRepository extends ReadableUserRepository { public function save(User $user): void; } class ReadOnlyUserRepository implements ReadableUserRepository { public function findById(int $id): User { return User::findOrFail($id); } }
Now any consumer expecting ReadableUserRepository receives a reliable, predictable interface without unexpected write exceptions.
I — Interface Segregation Principle (ISP)
Clients should never be forced to depend on interfaces they do not use. It is far better to create small, specific interfaces than a single bloated "fat" interface.
Bad Example: The "Fat" Interface
Code • php// Bad example: An all-in-one interface forcing classes to implement dummy methods interface OrderOperations { public function calculateTotal(int $orderId): float; public function capturePayment(int $orderId): bool; public function printPackingSlip(int $orderId): string; public function generateShippingLabel(int $orderId): string; } class DigitalProductOrderHandler implements OrderOperations { public function calculateTotal(int $orderId): float { return 29.99; } public function capturePayment(int $orderId): bool { return true; } // Digital products do not have shipping slips or physical labels! public function printPackingSlip(int $orderId): string { return ""; // Empty dummy implementation } public function generateShippingLabel(int $orderId): string { throw new \BadMethodCallException("Not applicable for digital downloads."); } }
Good Example: Focused, Segregated Interfaces
Code • php// Good example: Decompose into granular, purpose-built contracts namespace App\Contracts; interface PayableOrder { public function calculateTotal(int $orderId): float; public function capturePayment(int $orderId): bool; } interface PhysicalShippableOrder { public function printPackingSlip(int $orderId): string; public function generateShippingLabel(int $orderId): string; } // Digital orders only implement what they need: class DigitalProductOrderHandler implements PayableOrder { public function calculateTotal(int $orderId): float { return 29.99; } public function capturePayment(int $orderId): bool { return true; } }
D — Dependency Inversion Principle (DIP)
High-level modules should not depend upon low-level modules; both should depend upon abstractions. In Laravel, this principle is the foundation of the Service Container.
Bad Example: Hardcoded Concrete Instantiations
Code • php// Bad example: High-level service creates concrete low-level logger and database drivers class OrderService { private DiskLogger $logger; public function __construct() { // Directly coupled to a disk-based logger $this->logger = new DiskLogger('/var/log/orders.log'); } public function process(array $orderData): void { $this->logger->log("Processing order"); // ... } }
Good Example: Depend on Abstractions via Laravel Service Container
Code • php// Good example: Inject abstraction and bind in AppServiceProvider namespace App\Contracts; interface LoggerInterface { public function log(string $message): void; } namespace App\Services; use App\Contracts\LoggerInterface; class OrderService { public function __construct(private LoggerInterface $logger) {} public function process(array $orderData): void { $this->logger->log("Processing order"); } }
In your app/Providers/AppServiceProvider.php, bind the interface to whatever implementation you need:
Code • phppublic function register(): void { $this->app->bind( \App\Contracts\LoggerInterface::class, \App\Services\CloudWatchLogger::class ); }
When writing unit tests, swapping the real logger for a lightweight mock requires zero changes to OrderService.
Why Developers Face Confusion
Developers often struggle with SOLID principles for three predictable reasons:
- Premature Abstraction: Introducing interfaces and service patterns before actual complexity justifies them.
- Coupled Controllers: Placing business logic, database queries, and response formatting inside controller methods.
- Fear of File Proliferation: Worrying that having more focused classes makes a codebase larger, when in reality it makes each unit simpler to understand and test.
Practical Architectural Guidelines
- Listen to Your Unit Tests: When a class is difficult to test or requires 15 mock objects in its test setup, it is almost certainly violating SRP or DIP.
- Favor Composition Over Deep Inheritance: Rather than building tall class hierarchies, compose classes from smaller, interchangeable components.
- Leverage Laravel's Container: Let Laravel inject dependencies through constructor injection instead of using static facade calls across your domain services.
- Keep Form Requests and API Resources Thin: Validate with Form Requests, transform with API Resources, and keep domain calculations in dedicated service classes.
By adopting SOLID principles intentionally rather than dogmatically, your Laravel applications will remain clear, resilient, and enjoyable to maintain over years of growth.
Ersin Demirtas
Software Engineer based in London, UK. Building web applications, APIs, and tools with Laravel, TypeScript, React, and Next.js.
Related Articles
What Are the 4 Object-Oriented Principles?
Apr 11, 2025 —A clear exploration of the four foundational object-oriented programming principles and how they directly apply to modern Laravel applications.
Understanding and Solving the N+1 Problem in Laravel
May 20, 2025 —Learn about the N+1 problem, why it degrades application performance, and how to detect and resolve it in Laravel using eager loading.