SOLID Principles in Laravel: Practical Examples & Refactoring

Why SOLID matters in Laravel?

As Laravel projects grow, controllers and models can quickly become hard to change and test. SOLID principles help you keep code maintainable, flexible, and testable. In this tutorial we’ll walk through each principle with real Laravel examples and refactoring so you can apply them immediately.

What You’ll Learn

  • Single Responsibility Principle (SRP) refactor of a bloated controller
  • Open/Closed Principle (OCP) via strategy and polymorphism
  • Liskov Substitution Principle (LSP) by fixing contract violations
  • Interface Segregation Principle (ISP) by splitting fat interfaces
  • Dependency Inversion Principle (DIP) using interfaces and Laravel service container
  • Unit test examples using PHPUnit

Prerequisites

  1. PHP 8.3 or later for Laravel 13, or the PHP version required by your chosen Laravel release
  2. Laravel 13 (examples should be checked against your installed version)
  3. Composer and Artisan
  4. PHPUnit or the test runner configured by your Laravel project
  5. Basic familiarity with routes, controllers, Eloquent models, and dependency injection

Project Skeleton

app/
  Http/
    Controllers/
      OrderController.php
  Services/
    OrderService.php
  Repositories/
    OrderRepository.php
  Notifications/
    OrderPlacedNotification.php
tests/
  Feature/
  Unit/
routes/web.php

Single Responsibility Principle (SRP)

Single Responsibility Principle

Problem: a controller doing validation, business logic, persistence, and notification:

// app/Http/Controllers/OrderController.php (before)
namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Mail;

class OrderController extends Controller
{
    public function store(Request $request)
    {
        // validation
        $data = $request->only(['user_id', 'items', 'amount']);
        $validator = Validator::make($data, [
            'user_id' => 'required|integer',
            'items'   => 'required|array',
            'amount'  => 'required|numeric',
        ]);
        if ($validator->fails()) {
            return redirect()->back()->withErrors($validator)->withInput();
        }

        // business logic - calculate tax and totals
        $tax = $data['amount'] * 0.1;
        $total = $data['amount'] + $tax;

        // persistence
        $order = Order::create([
            'user_id' => $data['user_id'],
            'items'   => json_encode($data['items']),
            'amount'  => $total,
        ]);

        // notification
        Mail::to('admin@example.com')->send(new \App\Mail\OrderPlaced($order));

        return redirect()->route('orders.show', $order->id);
    }
}

Why SRP is violated: One method handles many responsibilities, like validation, tax logic, persistence, and notification.

Refactor (split responsibilities):

  • StoreOrderRequest – validates incoming request data.
  • OrderService – coordinates order creation.
  • Order model – represents the order and handles Eloquent persistence.
  • OrderPlaced event – announces that an order has been created.
  • SendOrderPlacedNotification listener – handles the notification separately from order creation.

This separation gives each component a clearer responsibility. For a small application, you may not need every component immediately; introduce them when the application benefits from the separation.

Create a Form Request:

php artisan make:request StoreOrderRequest
// app/Http/Requests/StoreOrderRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function rules()
    {
        return [
            'user_id' => 'required|integer',
            'items'   => 'required|array',
            'amount'  => 'required|numeric',
        ];
    }
}

OrderService:

// app/Services/OrderService.php
namespace App\Services;

use App\Events\OrderPlaced;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function create(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            $tax = $this->calculateTax((float) $data['amount']);

            $order = Order::create([
                'user_id' => $data['user_id'],
                'items' => $data['items'],
                'amount' => (float) $data['amount'] + $tax,
            ]);

            OrderPlaced::dispatch($order);

            return $order;
        });
    }

    private function calculateTax(float $amount): float
    {
        return $amount * 0.10;
    }
}

The service now coordinates the order-creation workflow and dispatches an event. A separate listener can handle email delivery, keeping notification logic outside the service. If the listener performs external side effects, configure it appropriately for your queue and transaction requirements.

Look at our thin controller now:

// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Services\OrderService;

class OrderController extends Controller
{
    protected OrderService $orderService;

    public function __construct(OrderService $orderService)
    {
        $this->orderService = $orderService;
    }

    public function store(StoreOrderRequest $request)
    {
        $order = $this->orderService->create($request->validated());
        return redirect()->route('orders.show', $order->id);
    }
}

Why this is better: The controller validates and coordinates the HTTP request, while the service coordinates order creation. The OrderPlaced event communicates that an order was created, allowing a separate listener to handle notification delivery. Each component now has a clearer responsibility, making changes and tests easier to manage.


Open/Closed Principle (OCP)

Open Closed Principle

Idea: Classes should be open for extension but closed for modification.

Scenario: Support multiple payment providers.

Bad (violation):

// app/Services/PaymentService.php
public function charge(string $method, $data)
{
    if ($method === 'stripe') {
        // stripe logic
    } elseif ($method === 'paypal') {
        // paypal logic
    } else {
        throw new \Exception('Unsupported');
    }
}

Better (OCP): Define PaymentGatewayInterface, implement StripeGateway, PaypalGateway; register via service container.

Interface:

// app/Contracts/PaymentGatewayInterface.php
namespace App\Contracts;

interface PaymentGatewayInterface
{
    public function charge(array $data): bool;
}

Stripe implementation:

// app/Gateways/StripeGateway.php
namespace App\Gateways;

use App\Contracts\PaymentGatewayInterface;

class StripeGateway implements PaymentGatewayInterface
{
    public function charge(array $data): bool
    {
        // stripe sdk calls...
        return true;
    }
}

PaymentService (depends on the interface):

// app/Services/PaymentService.php
namespace App\Services;

use App\Contracts\PaymentGatewayInterface;

class PaymentService
{
    protected PaymentGatewayInterface $gateway;

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

    public function charge(array $data): bool
    {
        return $this->gateway->charge($data);
    }
}

Stripe

// app/Providers/AppServiceProvider.php (register method)
use App\Contracts\PaymentGatewayInterface;
use App\Gateways\StripeGateway;

public function register()
{
    $this->app->bind(PaymentGatewayInterface::class, StripeGateway::class);
}

Note: The gateway implementation above is a structural example, not a working payment integration. Replace the placeholder with the payment provider’s SDK calls, appropriate error handling, and tests before using it in an application.

To add PayPal, create a PaypalGateway class that implements PaymentGatewayInterface. PaymentService can continue using the same interface, so its code does not need to change. You must still configure which gateway Laravel should resolve. For an application that selects providers dynamically, use an explicit configuration or a gateway resolver rather than assuming that one fixed service-container binding automatically supports every provider.

This illustrates the Open/Closed Principle: extend the available implementations while keeping the existing service independent of provider-specific code.


Liskov Substitution Principle (LSP)

The Liskov Substitution Principle says that an implementation should be usable wherever its contract is expected without breaking the behavior callers rely on. In PHP, this means more than matching method signatures: implementations must also respect the contract’s behavioral expectations.

Liskov Substitution Principle

Example: a report exporter

Suppose an application expects every exporter to return a string containing the exported report:

interface ReportExporter
{
    public function export(array $rows): string;
}

A CSV exporter can satisfy that contract:

class CsvReportExporter implements ReportExporter
{
    public function export(array $rows): string
    {
        $lines = [];

        foreach ($rows as $row) {
            $lines[] = implode(',', $row);
        }

        return implode("\n", $lines);
    }
}

Now imagine another implementation throws an exception whenever the report has more than one row, even though the interface and its documented contract permit multiple rows. A caller that works with ReportExporter can no longer safely substitute that implementation. The implementation has violated the behavioral contract.

How to prevent the violation

  • Define what each method accepts and what it guarantees to return.
  • Ensure every implementation respects those expectations.
  • Don’t add unexpected restrictions or failure conditions to a specific implementation.
  • Add contract tests that exercise every implementation against the same expected behavior.

The lesson is not that implementations can never throw exceptions. They should handle and document errors consistently with the contract callers rely on.


Interface Segregation Principle (ISP)

Interface Segregation Principle

Idea: Don’t force a class to implement interfaces it doesn’t need.

Bad: Big UserActionsInterface with create(), delete(), notify() but a class only handles notifications.

Good: Split into smaller interfaces:

<?php

interface CreatorInterface
{
    public function create(array $data): int;
}

interface DeleterInterface
{
    public function delete(int $id): bool;
}

interface NotifierInterface
{
    public function notify(string $message): void;
}

class NotificationService implements NotifierInterface
{
    public function notify(string $message): void
    {
        // Send or dispatch the notification here.
    }
}

NotificationService implements only NotifierInterface; it isn’t forced to provide methods for creating or deleting records. In a Laravel application, these interfaces can be injected into the classes that need them. Split interfaces when they represent genuinely separate capabilities, rather than creating an interface for every method by default.


Dependency Inversion Principle (DIP)

The Dependency Inversion Principle says that high-level business logic should depend on abstractions rather than directly constructing low-level implementations.

In Laravel, constructor injection and the service container make this pattern practical.

Dependency Inversion Principle

Before: directly constructing a repository

class OrderController extends Controller
{
    public function index()
    {
        $repository = new \App\Repositories\OrderRepository();

        return response()->json($repository->all());
    }
}

The controller chooses a concrete implementation itself. Replacing that implementation can require changes to the controller.

Step 1: Define the contract

// app/Contracts/OrderRepositoryInterface.php
namespace App\Contracts;

use App\Models\Order;
use Illuminate\Support\Collection;

interface OrderRepositoryInterface
{
    public function all(): Collection;

    public function find(int $id): ?Order;
}

Step 2: Implement the contract

// app/Repositories/EloquentOrderRepository.php
namespace App\Repositories;

use App\Contracts\OrderRepositoryInterface;
use App\Models\Order;
use Illuminate\Support\Collection;

class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function all(): Collection
    {
        return Order::query()->get();
    }

    public function find(int $id): ?Order
    {
        return Order::query()->find($id);
    }
}

Step 3: Bind the interface to its implementation

In app/Providers/AppServiceProvider.php, add the import and binding inside the existing register() method:

use App\Contracts\OrderRepositoryInterface;
use App\Repositories\EloquentOrderRepository;

public function register(): void
{
    $this->app->bind(
        OrderRepositoryInterface::class,
        EloquentOrderRepository::class
    );
}

If your register() method already contains other bindings, keep them and add this one rather than replacing the whole method.

Step 4: Inject the interface

namespace App\Http\Controllers;

use App\Contracts\OrderRepositoryInterface;
use App\Http\Controllers\Controller;

class OrderController extends Controller
{
    public function __construct(
        private readonly OrderRepositoryInterface $orders
    ) {}

    public function index()
    {
        return response()->json($this->orders->all());
    }
}

Laravel resolves the interface using the binding registered in the service container. The controller doesn’t need to construct the Eloquent repository directly.

Why this matters: The controller depends on a contract, not a particular implementation. That makes the dependency easier to replace in tests or in an application with different data-access requirements. A different implementation still needs to satisfy the same contract, and the binding must be configured accordingly.


Unit testing examples (PHPUnit)

Tests help verify that each component behaves as expected. Because the refactored OrderService persists an order and dispatches an event, this example uses Laravel’s database testing support and event fakes.

Test the order-creation workflow:

// tests/Feature/OrderServiceTest.php

namespace Tests\Feature;

use App\Events\OrderPlaced;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class OrderServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_create_persists_order_and_dispatches_event(): void
    {
        Event::fake([OrderPlaced::class]);

        $service = app(OrderService::class);

        $order = $service->create([
            'user_id' => 1,
            'items' => [['id' => 1]],
            'amount' => 100,
        ]);

        $this->assertInstanceOf(Order::class, $order);
        $this->assertEquals(110, (float) $order->amount);

        $this->assertDatabaseHas('orders', [
            'id' => $order->id,
            'user_id' => 1,
        ]);

        Event::assertDispatched(OrderPlaced::class);
    }
}

Before running this test: Your test database must have the required migrations, including the orders table and any user foreign-key dependencies. Configure a dedicated testing database or SQLite test database as appropriate for the project. The example assumes user_id = 1 satisfies your schema; if the column has a foreign-key constraint, create a corresponding user through a factory first.

Run the test with:

php artisan test --filter=OrderServiceTest

This test checks order creation and event dispatching. It does not verify that an email is delivered. Test the notification listener separately using Laravel’s mail fakes or notification fakes, depending on how you implement the listener.


Final Notes: Applying SOLID in Laravel

  • SOLID principles are guidelines for designing code that is easier to understand, change, and test. They are not a requirement to create a separate class or interface for every operation.
  • When applying these principles in Laravel:
  • SRP: Give controllers, services, listeners, and other classes clear responsibilities.
  • OCP: Use interfaces and interchangeable implementations when behavior needs to be extended.
  • LSP: Ensure implementations honor the behavioral contracts their callers depend on.
  • ISP: Keep interfaces focused on related capabilities.
  • DIP: Inject abstractions where doing so makes dependencies easier to replace or test.

Start with the complexity your application actually has. Introduce additional layers when they solve a real maintenance or testing problem, and verify the design with automated tests.

For further learning, consult the official Laravel documentation on the service container, events, and testing.


Also Read

pmwithmizan
pmwithmizan

Scrum Master & Project Manager with 6+ years delivering software at scale across international teams. Certified ScrumMaster (CSM) with a proven record of 95% on-time delivery, 90% client satisfaction, and cycle time reductions of up to 30%. Experienced in coaching teams, scaling Agile practices, and aligning engineering delivery with business outcomes. Skilled at RAID governance, forecasting, backlog refinement, and stakeholder management. Technical foundation in PHP/JS stacks, AWS, and databases ensures clear translation of technical trade-offs into business decisions.

Leave a Reply