Understanding SOLID Principles in Laravel Applications
The SOLID principles are a cornerstone of clean and maintainable software design. These principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—help developers build systems that are scalable, testable, and easy to maintain. In this article, we’ll explore each principle in the context of Laravel, providing practical examples along the way.
1. Single Responsibility Principle (SRP)
A class should have one and only one reason to change.
In Laravel, it’s common to see controllers doing too much: handling requests, processing business logic, and interacting with models. This violates SRP. Let’s fix this.
Anti-Pattern
class UserController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required',
'email' => 'required|email',
'password' => 'required|min:8',
]);
$user = new User();
$user->name = $validated['name'];
$user->email = $validated['email'];
$user->password = bcrypt($validated['password']);
$user->save();
return response()->json(['message' => 'User created successfully']);
}
}
Refactored Code
Here, we delegate validation and user creation to separate classes:
class UserController extends Controller
{
public function store(CreateUserRequest $request, UserService $userService)
{
$userService->create($request->validated());
return response()->json(['message' => 'User created successfully']);
}
}
// CreateUserRequest.php
class CreateUserRequest extends FormRequest
{
public function rules()
{
return [
'name' => 'required',
'email' => 'required|email',
'password' => 'required|min:8',
];
}
}
// UserService.php
class UserService
{
public function create(array $data)
{
$data['password'] = bcrypt($data['password']);
return User::create($data);
}
}
The controller now adheres to SRP by delegating responsibilities.
2. Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification.
Imagine you’re building a report generator. Initially, you only need to generate PDF reports, but later, you’re asked to add CSV and Excel support. Let’s design this with OCP in mind.
Implementation
Define a contract for report generation:
interface ReportGenerator
{
public function generate(array $data): string;
}
Create implementations for different formats:
class PdfReportGenerator implements ReportGenerator
{
public function generate(array $data): string
{
// Use a library like dompdf
return 'PDF report content';
}
}
class CsvReportGenerator implements ReportGenerator
{
public function generate(array $data): string
{
// Generate CSV content
return 'CSV report content';
}
}
Use dependency injection to remain open to new formats:
class ReportService
{
private ReportGenerator $reportGenerator;
public function __construct(ReportGenerator $reportGenerator)
{
$this->reportGenerator = $reportGenerator;
}
public function generateReport(array $data): string
{
return $this->reportGenerator->generate($data);
}
}
By injecting a new implementation of ReportGenerator
, you can extend functionality without modifying existing code.
3. Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types.
In Laravel, this principle often applies when extending base classes or implementing interfaces. For example, let’s ensure our payment methods adhere to LSP.
Implementation
Define a payment interface:
interface PaymentMethod
{
public function charge(float $amount): bool;
}
Implement specific payment methods:
class StripePayment implements PaymentMethod
{
public function charge(float $amount): bool
{
// Call Stripe API
return true;
}
}
class PayPalPayment implements PaymentMethod
{
public function charge(float $amount): bool
{
// Call PayPal API
return true;
}
}
Use the base type in your service:
class PaymentService
{
private PaymentMethod $paymentMethod;
public function __construct(PaymentMethod $paymentMethod)
{
$this->paymentMethod = $paymentMethod;
}
public function processPayment(float $amount)
{
$this->paymentMethod->charge($amount);
}
}
Substituting StripePayment
with PayPalPayment
works seamlessly without altering the PaymentService
logic.
4. Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they do not use.
In Laravel, using large interfaces can be tempting. Let’s see how to refine them.
Anti-Pattern
interface CrudOperations
{
public function create(array $data);
public function read(int $id);
public function update(int $id, array $data);
public function delete(int $id);
}
What if some entities don’t support all CRUD operations? For example, logs might not be updated or deleted.
Refactored Code
Split the interface into smaller contracts:
interface Creatable
{
public function create(array $data);
}
interface Readable
{
public function read(int $id);
}
Now implement only the relevant interfaces:
class LogService implements Readable, Creatable
{
public function create(array $data)
{
// Create log
}
public function read(int $id)
{
// Read log
}
}
This ensures that classes depend only on the methods they actually use.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
In Laravel, this is often achieved using dependency injection and service containers.
Anti-Pattern
class NotificationService
{
public function sendEmail(string $to, string $message)
{
// Send email logic
}
}
The NotificationService
depends directly on email implementation.
Refactored Code
Create an abstraction:
interface NotificationChannel
{
public function send(string $to, string $message);
}
Implement multiple channels:
class EmailChannel implements NotificationChannel
{
public function send(string $to, string $message)
{
// Send email logic
}
}
class SmsChannel implements NotificationChannel
{
public function send(string $to, string $message)
{
// Send SMS logic
}
}
Inject the abstraction:
class NotificationService
{
private NotificationChannel $channel;
public function __construct(NotificationChannel $channel)
{
$this->channel = $channel;
}
public function notify(string $to, string $message)
{
$this->channel->send($to, $message);
}
}
Now, swapping the notification channel requires no changes to the NotificationService
logic.
Conclusion
Applying the SOLID principles to your Laravel applications enhances their structure and maintainability. By designing classes and interfaces thoughtfully, you can create systems that are easier to test, extend, and debug. Embrace these principles, and watch your codebase flourish!
Top comments (1)
For the Single Responsibility Principle I think the controller example is a gray area.
I think testability and easy maintenance are very important if you apply the principles. Let us focus on the validation.
Should you test the validation? No because that is covered by the tests of the framework. Even if you have a custom rule, that should not be tested as part of a request data validation.
What is easier to maintain, a controller method or a controller method and a FormRequest class?
My rule of thumb is code that is used once doesn't need an abstraction. I break that rule if the validation is more complex than calling the validate method.
Rules and principles are fine as an anchor point, a lot of times they make sense. But there is no need to be dogmatic.