DEV Community

Cover image for SOLID Principle with Laravel
Nad Lambino
Nad Lambino

Posted on • Updated on

SOLID Principle with Laravel

SOLID Principle

SOLID Principle with Laravel

What is SOLID principle?

The SOLID principles are a set of five design principles that aim to guide developers in creating software systems that are modular, maintainable, and extensible. These principles provide guidelines for writing clean, robust, and flexible code. Each principle focuses on a specific aspect of software design and encourages the separation of concerns, flexibility, and adherence to good coding practices. By following the SOLID principles, developers can build software that is easier to understand, test, and modify, leading to improved quality and long-term sustainability.

Benefits of SOLID principle

  • Code maintainability: SOLID principles promote clean and organized code, making it easier to understand, modify, and maintain over time.
  • Code reusability: By adhering to SOLID principles, code becomes modular and loosely coupled, allowing for easier reuse in different parts of the application or in future projects.
  • Testability: SOLID principles encourage code that is easy to test in isolation, leading to more reliable and effective unit tests.
  • Flexibility and adaptability: Following SOLID principles results in code that is flexible and can be easily extended or modified to accommodate changing requirements or new features.
  • Collaboration: SOLID principles make code easier to understand and work with, facilitating better collaboration among team members.
  • Scalability: SOLID principles help in building scalable systems by enabling the creation of loosely coupled, modular components that can be easily scaled up or down as needed.
  • Reduced time and cost: Following SOLID principles from the start of a project can save time and reduce costs by minimizing bugs, refactoring needs, and making changes later in the development cycle.

By addressing these areas, the SOLID principles contribute to overall software quality, maintainability, and developer productivity.


Single Responsibility Principle

The Single Responsibility Principle (SRP) states that a class should have only one reason to change. It means that a class should have only one responsibility or job.

In the context of a Laravel application, let's consider a scenario where we have a UserController class that handles user-related operations like creating a new user, updating user information, and sending welcome emails. However, this violates the SRP because the class has multiple responsibilities.

Here's an example that violates the SRP

// UserController.php

class UserController
{
    public function create(Request $request)
    {
        // Validation and user creation logic

        $user = User::create($request->all());

        $this->sendWelcomeEmail($user); // Move this responsibility out of UserController
    }

    private function sendWelcomeEmail(User $user)
    {
        // Code to send the welcome email
    }
}
Enter fullscreen mode Exit fullscreen mode

Here's an example that follows the SRP

// UserController.php

class UserController
{
    public function create(Request $request, EmailService $emailService)
    {
        // Validation and user creation logic

        $user = User::create($request->all());

        // Delegate the responsibility to the EmailService class
        $emailService->sendWelcomeEmail($user);
    }
}
Enter fullscreen mode Exit fullscreen mode
// EmailService.php

class EmailService
{
    public function sendWelcomeEmail(User $user)
    {
        // Code to send the welcome email
    }

    public function sendEmailWithAttachment()
    {
        // Code to send email with attachment
    }
}
Enter fullscreen mode Exit fullscreen mode

In the refactored code, we extract the responsibility of sending a welcome email into a separate EmailService class. This separates the concerns, allowing the UserController to focus solely on user-related operations, while the EmailService class handles email-related tasks. This makes its functions to be reusable on other controllers or service that needs mail related tasks without repeating our code. This adheres to the SRP, as each class now has a single responsibility, making the code more modular, maintainable, and easier to extend or change in the future.


Open-Closed Principle

The Open-Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In simple terms, it means that you should be able to add new functionality to a system without modifying its existing code.

Let's consider an example in a Laravel application where we have a PaymentController that handles different payment methods: PayPal and Stripe. Initially, the controller has a switch statement to determine the payment method and perform the corresponding actions.

// PaymentController.php

class PaymentController
{
    public function processPayment(Request $request)
    {
        $paymentMethod = $request->input('payment_method');

        switch ($paymentMethod) {
            case 'paypal':
                $this->processPayPalPayment($request);
                break;
            case 'stripe':
                $this->processStripePayment($request);
                break;
            default:
                // Handle unsupported payment method
                break;
        }
    }

    private function processPayPalPayment(Request $request)
    {
        // Code for processing PayPal payment
    }

    private function processStripePayment(Request $request)
    {
        // Code for processing Stripe payment
    }
}
Enter fullscreen mode Exit fullscreen mode

In the above code, adding a new payment method would require modifying the PaymentController by adding another case to the switch statement. This violates the OCP because we are modifying the existing code instead of extending it.

To adhere to the OCP, we can use a strategy pattern to decouple the payment processing logic from the controller and make it open for extension. Here's an updated version:

// PaymentController.php

class PaymentController
{
    private $paymentProcessor;

    public function __construct(PaymentProcessorInterface $paymentProcessor)
    {
        $this->paymentProcessor = $paymentProcessor;
    }

    public function processPayment(Request $request)
    {
        $this->paymentProcessor->processPayment($request);
    }
}
Enter fullscreen mode Exit fullscreen mode
// PaymentProcessorInterface.php

interface PaymentProcessorInterface
{
    public function processPayment(Request $request);
}
Enter fullscreen mode Exit fullscreen mode
// PayPalPaymentProcessor.php

class PayPalPaymentProcessor implements PaymentProcessorInterface
{
    public function processPayment(Request $request)
    {
        // Code for processing PayPal payment
    }
}
Enter fullscreen mode Exit fullscreen mode
// StripePaymentProcessor.php

class StripePaymentProcessor implements PaymentProcessorInterface
{
    public function processPayment(Request $request)
    {
        // Code for processing Stripe payment
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, to dynamically select the payment processor based on user input, you can leverage Laravel's container and configuration capabilities. Here's an example:

// config/payments.php

return [
    'default' => 'stripe',
    'processors' => [
        'paypal' => PayPalPaymentProcessor::class,
        'stripe' => StripePaymentProcessor::class,
    ],
];
Enter fullscreen mode Exit fullscreen mode
// PaymentServiceProvider.php

use Illuminate\Support\Facades\App;
use Illuminate\Support\ServiceProvider;

class PaymentServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(PaymentProcessorInterface::class, function ($app) {
            $config = $app['config']->get('payments');
            $defaultProcessor = $config['default'];
            $processors = $config['processors'];

            $selectedProcessor = $request->input('payment_method', $defaultProcessor);
            $processorClass = $processors[$selectedProcessor];

            return $app->make($processorClass);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Liskov Substitution Principle

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In simpler terms, it means that subclasses should be able to be used interchangeably with the base class without causing any unexpected behavior.

Let's consider a real-world example where we have a base class called Vehicle and two subclasses called Car and Bicycle. Each of them has a method called startEngine(), which represents starting the engine of the vehicle.

Here's an example that violates the Liskov Substitution Principle:

class Vehicle {
    public function startEngine() {
        // Default implementation for starting the engine
        echo "Engine started!";
    }
}

Enter fullscreen mode Exit fullscreen mode
class Car extends Vehicle {
    public function startEngine() {
        // Implementation specific to starting a car's engine
        echo "Car engine started!";
    }
}

Enter fullscreen mode Exit fullscreen mode
class Bicycle extends Vehicle {
    public function startEngine() {
        // Bicycles don't have engines, so this violates LSP
        throw new Exception("Bicycles don't have engines!");
    }
}

Enter fullscreen mode Exit fullscreen mode

In the above code, the Bicycle class violates the Liskov Substitution Principle because it throws an exception when trying to start the engine. This behavior is unexpected and breaks the principle.

Here's an example that follows the Liskov Substitution Principle:

class Vehicle {
    // Common implementation for all vehicles
    public function startEngine() {
        // Default implementation for starting the engine
        echo "Engine started!";
    }
}

Enter fullscreen mode Exit fullscreen mode
class Car extends Vehicle {
    public function startEngine() {
        // Implementation specific to starting a car's engine
        echo "Car engine started!";
    }
}

Enter fullscreen mode Exit fullscreen mode
class Bicycle extends Vehicle {
    // Bicycles don't have engines, so we don't override the startEngine() method
}

Enter fullscreen mode Exit fullscreen mode

In the above code, the Bicycle class follows the Liskov Substitution Principle by not overriding the startEngine() method. Since bicycles don't have engines, the default implementation from the base class is used, which is acceptable and doesn't introduce unexpected behavior.

By following LSP, you ensure that your code is more maintainable, extensible, and less prone to bugs, as you can safely use objects of subclasses wherever objects of the base class are expected.


Interface Segregation Principle

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. In simpler terms, it means that a class should not be forced to implement methods that it doesn't need.

Let's consider a real-world example of an online store application built with Laravel.

Violating the Interface Segregation Principle:

interface PaymentGatewayInterface {
    public function processPayment($amount);
    public function refundPayment($transactionId);
    public function voidPayment($transactionId);
}

Enter fullscreen mode Exit fullscreen mode
class PaymentGateway implements PaymentGatewayInterface {
    public function processPayment($amount) {
        // Process payment logic
    }

    public function refundPayment($transactionId) {
        // Refund payment logic
    }

    public function voidPayment($transactionId) {
        // Void payment logic
    }
}

Enter fullscreen mode Exit fullscreen mode
class EcommerceService {
    private $paymentGateway;

    public function __construct(PaymentGatewayInterface $paymentGateway) {
        $this->paymentGateway = $paymentGateway;
    }

    public function processOrder($order) {
        // Process order logic

        $this->paymentGateway->processPayment($order->totalAmount);
    }

    public function refundOrder($order) {
        // Refund order logic

        $this->paymentGateway->refundPayment($order->transactionId);
    }

    public function voidOrder($order) {
        // Void order logic

        $this->paymentGateway->voidPayment($order->transactionId);
    }
}

Enter fullscreen mode Exit fullscreen mode

In this example, the PaymentGatewayInterface defines three methods: processPayment(), refundPayment(), and voidPayment(). However, in the EcommerceService class, we only need to use the processPayment() method to handle payment-related operations. The refundPayment() and voidPayment() methods are not relevant to the EcommerceService, but we're still forced to depend on them because the interface enforces their implementation.

Here's a modified version that follows the ISP:

interface PaymentProcessorInterface {
    public function processPayment($amount);
}

Enter fullscreen mode Exit fullscreen mode
interface RefundableInterface {
    public function refundPayment($transactionId);
}

Enter fullscreen mode Exit fullscreen mode
interface VoidableInterface {
    public function voidPayment($transactionId);
}

Enter fullscreen mode Exit fullscreen mode
class PaymentGateway implements PaymentProcessorInterface, RefundableInterface, VoidableInterface {
    public function processPayment($amount) {
        // Process payment logic
    }

    public function refundPayment($transactionId) {
        // Refund payment logic
    }

    public function voidPayment($transactionId) {
        // Void payment logic
    }
}

Enter fullscreen mode Exit fullscreen mode
class EcommerceService {
    private $paymentProcessor;

    public function __construct(PaymentProcessorInterface $paymentProcessor) {
        $this->paymentProcessor = $paymentProcessor;
    }

    public function processOrder($order) {
        // Process order logic

        $this->paymentProcessor->processPayment($order->totalAmount);
    }
}

Enter fullscreen mode Exit fullscreen mode

In this updated example, the PaymentProcessorInterface defines only the processPayment() method, which is the only method needed by the EcommerceService. The RefundableInterface and VoidableInterface are created for other classes that require those specific functionalities. By separating the interfaces, we adhere to the ISP by allowing clients to depend only on the interfaces they actually need.

This adherence to the ISP improves the codebase's maintainability, reduces unnecessary dependencies, and makes it easier for entry-level developers to understand and work with the code.


Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules, but both should depend on abstractions. In other words, instead of depending on specific implementations, modules should rely on interfaces or abstract classes.

Code Sample Violating DIP in Laravel using the Storage Facade:

class UserController extends Controller
{
    public function store(Request $request)
    {
        $avatar = $request->file('avatar');

        // Violation: The UserController depends directly on the Storage facade.
        // This makes it tightly coupled to the Laravel's file storage implementation.
        $path = Storage::disk('local')->put('avatars', $avatar);

        // ...
    }
}

Enter fullscreen mode Exit fullscreen mode

The UserController class relies on the Storage facade and calls its disk and put methods directly. By depending on the Storage facade directly, the UserController is tightly coupled to the specific file storage system implemented by Laravel, making it harder to switch to a different storage mechanism without modifying the UserController code.

Code Sample Following DIP in Laravel using the Storage Service:

interface FileStorage
{
    public function storeFile($directory, $file);
}

Enter fullscreen mode Exit fullscreen mode
class LocalFileStorage implements FileStorage
{
    public function storeFile($directory, $file)
    {
        return Storage::disk('local')->put($directory, $file);
    }
}

Enter fullscreen mode Exit fullscreen mode
class S3FileStorage implements FileStorage
{
    public function storeFile($directory, $file)
    {
        return Storage::disk('s3')->put($directory, $file);
    }
}

Enter fullscreen mode Exit fullscreen mode
class UserController extends Controller
{
    private $fileStorage;

    public function __construct(FileStorage $fileStorage)
    {
        $this->fileStorage = $fileStorage;
    }

    public function store(Request $request)
    {
        $avatar = $request->file('avatar');

        // The UserController depends on the FileStorage abstraction, which can be
        // implemented using different storage systems.
        $path = $this->fileStorage->storeFile('avatars', $avatar);

        // ...
    }
}

Enter fullscreen mode Exit fullscreen mode

The UserController class now depends on the FileStorage interface instead of directly relying on the Storage facade or a specific implementation. This interface serves as an abstraction that defines the contract for file storage operations.

The S3FileStorage class implements the FileStorage interface and provides the concrete implementation for storing files in AWS S3. By injecting the FileStorage interface into the UserController constructor, the controller is decoupled from the specific storage mechanism and depends only on the abstraction.
This adherence to the Dependency Inversion Principle allows for easier interchangeability of storage implementations. You can easily introduce new implementations of the FileStorage interface (e.g., a LocalFileStorage class) without modifying the UserController code. The choice of storage mechanism can be determined at runtime or through configuration (.env file), providing flexibility and maintainability.


Conclusion:

The SOLID principles are guidelines for writing clean and maintainable code. While they offer numerous benefits, they should not be seen as rigid rules that must always be followed without exception. It's crucial to strike a balance between applying the SOLID principles and considering other factors such as project complexity.

Thank you for reading.

Top comments (0)