DEV Community

Cover image for Understanding SOLID Principles in Laravel Applications
Miguel Lopez
Miguel Lopez

Posted on • Originally published at Medium

Understanding SOLID Principles in Laravel Applications

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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';
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

The NotificationService depends directly on email implementation.

Refactored Code

Create an abstraction:

interface NotificationChannel
{
    public function send(string $to, string $message);
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
xwero profile image
david duymelinck

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.