SOLID Principles in Laravel: Practical Examples & Refactoring - PMwithMizan

SOLID Principles in Laravel: Practical Examples & Refactoring

Overview Diagram of SOLID Principles

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 7.4+
  2. Laravel 9+
  3. Composer, artisan, PHPUnit
  4. Basic knowledge of routes, controllers, 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):

  • OrderRequest (validation)
  • OrderService (business logic + persistence)
  • OrderPlacedNotification (notification)

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\Models\Order;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderPlaced;

class OrderService
{
    public function create(array $data): Order
    {
        $tax = $this->calculateTax($data['amount']);
        $data['amount'] = $data['amount'] + $tax;
        $order = Order::create([
            'user_id' => $data['user_id'],
            'items'   => json_encode($data['items']),
            'amount'  => $data['amount'],
        ]);

        Mail::to('admin@example.com')->send(new OrderPlaced($order));
        return $order;
    }

    protected function calculateTax(float $amount): float
    {
        return $amount * 0.1;
    }
}

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: Controller now has one responsibility: coordinate request -> service -> response. The business logic and notifications live in the service.


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);
    }
}

Binding in Service Provider:

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

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

Now to support PayPal: just create PaypalGateway implementing the interface and change binding (or use config to choose binding); no changes are needed to PaymentService.


Liskov Substitution Principle (LSP)

Liskov Substitution Principle

Idea: Subtypes must be substitutable for their base types.

Violation example: Suppose you have ReportExporter interface that exports to CSV and some implementations throw exceptions for certain method parameters, breaking expectations.

Fix: Ensure child classes follow the same contract and behavior. For Laravel example — avoid changing method signatures or throwing unexpected exceptions; use Feature flags or composition.

Short practical tip: If you design interfaces in Laravel, ensure each implementation respects the same preconditions/postconditions.


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:

interface CreatorInterface { public function create(array $data); }
interface DeleterInterface  { public function delete(int $id); }
interface NotifierInterface { public function notify($message); }

Then NotificationService implements NotifierInterface only.


Dependency Inversion Principle (DIP)

Dependency Inversion Principle

Idea: High-level modules should not depend on low-level modules; both depend on abstractions.

Laravel Example (before):

// within controller
$repository = new \App\Repositories\OrderRepository();

After (DIP + DI):

// bind interface to repository in service provider
$this->app->bind(OrderRepositoryInterface::class, EloquentOrderRepository::class);

// controller receive interface
public function __construct(OrderRepositoryInterface $orders)
{
    $this->orders = $orders;
}

Benefits: Easier to test and swap implementations (example: use in-memory repo in tests).


Unit testing examples (PHPUnit)

Test for OrderService (SRP refactor):

// tests/Unit/OrderServiceTest.php
use Tests\TestCase;
use App\Services\OrderService;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderPlaced;

class OrderServiceTest extends TestCase
{
    public function testCreateSendsEmailAndCreatesOrder()
    {
        Mail::fake();

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

        $this->assertInstanceOf(Order::class, $order);
        Mail::assertSent(OrderPlaced::class);
    }
}

Run:

vendor/bin/phpunit --filter OrderServiceTest

Final Notes: Tips & Best Practices

  • Keep controllers thin — put logic in services, jobs, or domain classes.
  • Use interfaces + DI for flexible code.
  • Write tests for services and repositories.
  • Prefer composition over multi-responsibility inheritance.
  • Use Form Requests for validation and Events / Listeners for async behavior.

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