The SOLID principles: how to use them in Laravel to write better code
The SOLID principles are a set of guidelines for writing clean and maintainable code that is easy to understand, modify, and extend. In Laravel, you can use these principles to write better code by following some best practices.
SOLID is an acronym for five design principles used in object-oriented programming (OOP). Laravel is a PHP web application framework that follows the principles of OOP and, therefore, naturally implements the SOLID principles.
This is the complete guide to using SOLID principles in Laravel.
Overall, Laravel’s architecture and design patterns naturally align with the SOLID principles, making it a popular choice for developers who want to write clean, maintainable code. Now, You will know how to use SOLID principles in Laravel.
Here is a brief overview of how SOLID principles are implemented in Laravel.
1. Single Responsibility Principle (SRP):
A class should have only one reason to change. It means that each class should have only one responsibility or function. In Laravel, you can follow this principle by creating separate classes for each function or responsibility, such as controllers for handling HTTP requests, models for database interaction, and services for business logic.
here’s an example code for the TaskController violating the Single Responsibility Principle and its fix:
// Example code that violates the Single Responsibility Principle class TaskController { public function index() { $tasks = DB::table('tasks')->get(); return view('tasks.index', ['tasks' => $tasks]); } public function create(Request $request) { DB::table('tasks')->insert([ 'title' => $request->input('title'), 'description' => $request->input('description'), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); return redirect()->route('tasks.index'); } public function edit(Request $request, $id) { $task = DB::table('tasks')->where('id', $id)->first(); return view('tasks.edit', ['task' => $task]); } public function update(Request $request, $id) { DB::table('tasks') ->where('id', $id) ->update([ 'title' => $request->input('title'), 'description' => $request->input('description'), 'updated_at' => Carbon::now(), ]); return redirect()->route('tasks.index'); } public function delete(Request $request, $id) { DB::table('tasks')->where('id', $id)->delete(); return redirect()->route('tasks.index'); } }
In the above code, the TaskController is responsible for handling HTTP requests related to tasks as well as database operations related to tasks. This violates the Single Responsibility Principle as the controller is doing more than one thing.
Here’s the fix for the TaskController using the TaskRepository:
// app/Http/Controllers/TaskController.php namespace App\Http\Controllers; use App\Repositories\TaskRepository; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Redirect; use Illuminate\View\View; class TaskController extends Controller { protected $taskRepository; public function __construct(TaskRepository $taskRepository) { $this->taskRepository = $taskRepository; } public function index(): View { $tasks = $this->taskRepository->getAllTasks(); return view('tasks.index', ['tasks' => $tasks]); } public function create(Request $request): Redirect { $this->taskRepository->createTask( $request->input('title'), $request->input('description') ); return redirect()->route('tasks.index'); } public function edit(Request $request, $id): View { $task = $this->taskRepository->findTaskById($id); return view('tasks.edit', ['task' => $task]); } public function update(Request $request, $id): Redirect { $this->taskRepository->updateTask( $id, $request->input('title'), $request->input('description') ); return redirect()->route('tasks.index'); } public function delete(Request $request, $id): Redirect { $this->taskRepository->deleteTask($id); return redirect()->route('tasks.index'); } }
// app/Repositories/TaskRepository.php namespace App\Repositories; use Carbon\Carbon; use Illuminate\Support\Facades\DB; class TaskRepository { public function getAllTasks() { return DB::table('tasks')->get(); } public function createTask($title, $description) { DB::table('tasks')->insert([ 'title' => $title, 'description' => $description, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); } public function findTaskById($id) { return DB::table('tasks')->where('id', $id)->first(); } public function updateTask($id, $title, $description) { DB::table('tasks') ->where('id', $id) ->update([ 'title' => $title, 'description' => $description, 'updated_at' => Carbon::now(), ]); } public function deleteTask($id) { DB::table('tasks')->where('id', $id)->delete(); } }
In the example above, we’ve created a TaskRepository
class with its own namespace, and we’re using it TaskController
by injecting an instance of it into the controller’s constructor. This way, we can separate the concerns of handling HTTP requests and handling database operations into separate classes.
2. Open-Closed Principle (OCP):
The code should be open for extension but closed for modification. This means that you should be able to add new features to the application without having to modify the existing code. In Laravel, this can be achieved by using interfaces and abstract classes.
here’s an example of how the Open/Closed Principle can be violated in a Laravel application and how it can be fixed:
Let’s suppose we have a Product
model with a getDiscountedPrice
method that calculates the discounted price of a product based on its regular price and the discount percentage.
// app/Models/Product.php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Product extends Model { public function getDiscountedPrice($discountPercentage) { $discountAmount = $this->price * ($discountPercentage / 100); $discountedPrice = $this->price - $discountAmount; return $discountedPrice; } }
Now suppose we want to add a new type of discount that applies a fixed discount amount instead of a percentage discount. One way to do this is to modify the getDiscountedPrice
method to accept a new parameter that indicates whether the discount is a percentage or a fixed amount.
// app/Models/Product.php (violation of OCP) namespace App\Models; use Illuminate\Database\Eloquent\Model; class Product extends Model { public function getDiscountedPrice($discountAmount, $isPercentageDiscount = true) { if ($isPercentageDiscount) { $discountAmount = $this->price * ($discountAmount / 100); } $discountedPrice = $this->price - $discountAmount; return $discountedPrice; } }
This modification violates the Open/Closed Principle because we have to modify existing code to add new functionality.
To fix this violation, we can use the Strategy pattern and interfaces to abstract away the implementation details of our discount calculations. First, let’s create an interface for our discount calculator:
// app/Services/DiscountCalculatorInterface.php namespace App\Services; use App\Models\Product; interface DiscountCalculatorInterface { public function calculateDiscount(Product $product, $discountValue); }
The DiscountCalculatorInterface
defines a single method calculateDiscount
which takes a Product
instance and the discount value and returns the discounted price.
Now let’s create two implementations of the DiscountCalculatorInterface
interface – one for percentage discounts and one for fixed amount discounts:
// app/Services/PercentageDiscountCalculator.php namespace App\Services; use App\Models\Product; class PercentageDiscountCalculator implements DiscountCalculatorInterface { public function calculateDiscount(Product $product, $discountValue) { $discountAmount = $product->price * ($discountValue / 100); $discountedPrice = $product->price - $discountAmount; return $discountedPrice; } }
// app/Services/FixedAmountDiscountCalculator.php namespace App\Services; use App\Models\Product; class FixedAmountDiscountCalculator implements DiscountCalculatorInterface { public function calculateDiscount(Product $product, $discountValue) { $discountedPrice = $product->price - $discountValue; return $discountedPrice; } }
Next, let’s modify the Product
model to accept an instance of DiscountCalculatorInterface
and use it to calculate the discounted price:
// app/Models/Product.php (fixed using OCP) namespace App\Models; use Illuminate\Database\Eloquent\Model; use App\Services\DiscountCalculatorInterface; class Product extends Model { public function getDiscountedPrice(DiscountCalculatorInterface $calculator, $discountValue) { return $calculator->calculateDiscount($this, $discountValue); } }
Now we can use any implementation of the DiscountCalculatorInterface
interface to calculate the discounted price, without modifying the existing code.
3. Liskov Substitution Principle (LSP):
Any object of a class should be able to be replaced with an object of its subclass without affecting the correctness of the program. This means that any subclass of a given class should behave in a consistent manner with the parent class.
here’s a fresh example of the Liskov Substitution Principle violation and its fix in Laravel.
Let’s suppose we have a PaymentGateway
class with a processPayment
method that takes payment information and processes the payment:
// app/Services/PaymentGateway.php namespace App\Services; class PaymentGateway { public function processPayment($paymentInfo) { // Code to process payment } }
Now suppose we want to add a new payment method that requires additional information, such as a billing address. One way to do this is to modify the processPayment
method to accept the additional information as a parameter.
// app/Services/PaymentGateway.php (violation of LSP) namespace App\Services; class PaymentGateway { public function processPayment($paymentInfo, $billingAddress) { // Code to process payment } }
This modification violates the Liskov Substitution Principle because any code that depends on the PaymentGateway
class might break if it expects the method signature to be just processPayment($paymentInfo)
.
To fix this violation, we can use inheritance and create a new class ExtendedPaymentGateway
that inherits from PaymentGateway
and adds the required functionality.
// app/Services/ExtendedPaymentGateway.php (fixed using LSP) namespace App\Services; class ExtendedPaymentGateway extends PaymentGateway { public function processPayment($paymentInfo, $billingAddress) { // Code to process payment with billing address } }
Now we can use the ExtendedPaymentGateway
class wherever we need to process payments that require a billing address, and still use the PaymentGateway
class for payments that don’t require a billing address. This way, we can avoid breaking the Liskov Substitution Principle and ensure that our code is more flexible and extensible.
4. Interface Segregation Principle (ISP):
Clients should not be forced to depend on interfaces they do not use. This means that the application should be designed in such a way that each module only depends on the interfaces that it needs.
Here is an example of the Interface Segregation Principle (ISP) violation in Laravel and how to fix it:
Let’s say you have an interface called PaymentGateway
that defines two methods: processPayment()
and refundPayment()
.
interface PaymentGateway { public function processPayment($amount); public function refundPayment($transactionId); }
Now, you have a payment service called PayPalPaymentService
that implements the PaymentGateway
interface:
class PayPalPaymentService implements PaymentGateway { public function processPayment($amount) { // process payment using PayPal } public function refundPayment($transactionId) { // refund payment using PayPal } }
However, your application also needs to support payment processing via a different payment gateway, say Stripe. But Stripe doesn’t support refunds, so you end up implementing a dummy refundPayment()
method in the StripePaymentService
class:
class StripePaymentService implements PaymentGateway { public function processPayment($amount) { // process payment using Stripe } public function refundPayment($transactionId) { // Do nothing - Stripe doesn't support refunds } }
This violates the Interface Segregation Principle because the StripePaymentService
class is forced to implement a method it doesn’t need. It also makes the code harder to maintain and understand, as the implementation of refundPayment()
in StripePaymentService
is empty and potentially misleading.
To fix this violation, we can create two separate interfaces: PaymentProcessor
for payment processing, and PaymentRefunder
for payment refunds:
interface PaymentProcessor { public function processPayment($amount); } interface PaymentRefunder { public function refundPayment($transactionId); }
Then, we can create two separate classes for each payment gateway that implement only the interface(s) they need:
class PayPalPaymentService implements PaymentProcessor { public function processPayment($amount) { // process payment using PayPal } } class StripePaymentService implements PaymentProcessor, PaymentRefunder { public function processPayment($amount) { // process payment using Stripe } public function refundPayment($transactionId) { // refund payment using Stripe } }
Now, each class implements only the interface(s) it needs, and we don’t need to create a dummy implementation of refundPayment()
in StripePaymentService
. This makes the code easier to maintain and understand, and also makes it easier to add support for new payment gateways in the future.
5. Dependency Inversion Principle (DIP):
High-level modules should not depend on low-level modules. Instead, both should depend on abstractions. It means that you should depend on abstractions rather than concrete implementations. In Laravel, you can follow this principle by using interfaces or abstract classes to define contracts between modules and by using dependency injection to inject dependencies at runtime.
here’s an example of Dependency Inversion Principle (DIP) violation in Laravel and how to fix it, with namespaces.
Let’s say we have a UserController
class that depends on a concrete implementation of a UserRepository
class:
namespace App\Http\Controllers; use App\UserRepository; class UserController extends Controller { private $userRepository; public function __construct(UserRepository $userRepository) { $this->userRepository = $userRepository; } public function index() { $users = $this->userRepository->getAllUsers(); return view('users.index', compact('users')); } }
In this example, the UserController
has a constructor that takes an instance of UserRepository
as a dependency. This creates a tight coupling between the UserController
and the UserRepository
class, which violates the Dependency Inversion Principle.
To fix this violation, we can use dependency injection to inject an interface instead of a concrete class:
namespace App\Repositories; use App\UserRepository; interface UserRepositoryInterface extends UserRepository { public function getAllUsers(); } class UserRepositoryImpl implements UserRepositoryInterface { public function getAllUsers() { return User::all(); } }
We have created an interface called UserRepositoryInterface
that extends the UserRepository
interface and defines the getAllUsers()
method. Then, we created a concrete implementation of the interface called UserRepositoryImpl
.
Now, we can use the UserRepositoryInterface
in the UserController
class:
namespace App\Http\Controllers; use App\Repositories\UserRepositoryInterface; class UserController extends Controller { private $userRepository; public function __construct(UserRepositoryInterface $userRepository) { $this->userRepository = $userRepository; } public function index() { $users = $this->userRepository->getAllUsers(); return view('users.index', compact('users')); } }
We can use Laravel’s service container to bind the UserRepositoryInterface
to the UserRepositoryImpl
:
// in App\Providers\AppServiceProvider use App\Repositories\UserRepositoryImpl; use App\Repositories\UserRepositoryInterface; public function register() { $this->app->bind(UserRepositoryInterface::class, UserRepositoryImpl::class); }
By binding the interface to the concrete implementation in the service container, we can now inject the UserRepositoryInterface
into the UserController
class:
namespace App\Http\Controllers; use App\Repositories\UserRepositoryInterface; class UserController extends Controller { private $userRepository; public function __construct(UserRepositoryInterface $userRepository) { $this->userRepository = $userRepository; } public function index() { $users = $this->userRepository->getAllUsers(); return view('users.index', compact('users')); } }
Now, we have successfully applied the Dependency Inversion Principle and created a loosely coupled system that allows for easier testing and maintainability.