SOLID in Laravel: Real Before/After Code Transformations for Every Principle

SOLID principles get thrown around constantly in job interviews and code reviews, yet most Laravel projects violate at least three of them by the time they hit production. This is not a theoretical treatise - it is a field guide with real before/after examples from the kind of code you encounter every day in Laravel applications. Each principle is shown as a concrete transformation: ugly code you recognize, clean code you can ship.

📋 Table of Contents


🔴 Single Responsibility Principle (SRP)

A class should have only one reason to change.

BEFORE - the fat controller antipattern:

<?php
// app/Http/Controllers/Api/OrderController.php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Models\Order;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        // Validation mixed into the controller
        $request->validate([
            'items'           => 'required|array|min:1',
            'items.*.id'      => 'required|integer|exists:products,id',
            'items.*.qty'     => 'required|integer|min:1',
            'shipping_method' => 'required|in:standard,express',
            'coupon_code'     => 'nullable|string',
        ]);

        // Business logic in the controller
        $total = 0;
        foreach ($request->items as $item) {
            $product = \App\Models\Product::find($item['id']);
            $total  += $product->price_cents * $item['qty'];
        }

        if ($request->coupon_code) {
            $coupon = \App\Models\Coupon::where('code', $request->coupon_code)->firstOrFail();
            $total  = (int) ($total * (1 - $coupon->discount_rate));
        }

        $order = Order::create([
            'user_id'         => auth()->id(),
            'total_cents'     => $total,
            'shipping_method' => $request->shipping_method,
            'status'          => 'pending',
        ]);

        foreach ($request->items as $item) {
            $order->items()->create([
                'product_id' => $item['id'],
                'quantity'   => $item['qty'],
                'price_cents' => \App\Models\Product::find($item['id'])->price_cents,
            ]);
        }

        // Notification inside the controller
        \Mail::to(auth()->user())->send(new \App\Mail\OrderConfirmation($order));
        \App\Models\User::find(1)->notify(new \App\Notifications\NewOrderAlert($order));

        return response()->json(['data' => $order->load('items')], 201);
    }
}

This controller has at least four reasons to change: validation rules change, pricing logic changes, notification strategy changes, response format changes. It also hides N+1 queries inside that loop.

AFTER - separated responsibilities:

<?php
// app/Http/Requests/Api/StoreOrderRequest.php

declare(strict_types=1);

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'items'           => ['required', 'array', 'min:1'],
            'items.*.id'      => ['required', 'integer', 'exists:products,id'],
            'items.*.qty'     => ['required', 'integer', 'min:1'],
            'shipping_method' => ['required', 'in:standard,express'],
            'coupon_code'     => ['nullable', 'string', 'exists:coupons,code'],
        ];
    }
}
<?php
// app/Actions/Orders/CreateOrderAction.php

declare(strict_types=1);

namespace App\Actions\Orders;

use App\DataTransferObjects\CreateOrderData;
use App\Models\Coupon;
use App\Models\Order;
use App\Models\Product;
use App\Notifications\NewOrderAlert;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;

class CreateOrderAction
{
    public function execute(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data): Order {
            $products = Product::findMany(
                collect($data->items)->pluck('id')
            )->keyBy('id');

            $total = collect($data->items)->sum(
                fn ($item) => $products[$item['id']]->price_cents * $item['qty']
            );

            if ($data->couponCode) {
                $coupon = Coupon::where('code', $data->couponCode)->firstOrFail();
                $total  = (int) ($total * (1 - $coupon->discount_rate));
            }

            $order = Order::create([
                'user_id'         => $data->userId,
                'total_cents'     => $total,
                'shipping_method' => $data->shippingMethod,
                'status'          => 'pending',
            ]);

            $order->items()->createMany(
                collect($data->items)->map(fn ($item) => [
                    'product_id'  => $item['id'],
                    'quantity'    => $item['qty'],
                    'price_cents' => $products[$item['id']]->price_cents,
                ])->all()
            );

            return $order->load('items');
        });
    }
}
<?php
// app/Http/Controllers/Api/OrderController.php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\Orders\CreateOrderAction;
use App\DataTransferObjects\CreateOrderData;
use App\Http\Requests\Api\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use Illuminate\Http\JsonResponse;

class OrderController extends Controller
{
    public function store(
        StoreOrderRequest $request,
        CreateOrderAction $action,
    ): JsonResponse {
        $order = $action->execute(
            CreateOrderData::fromRequest($request)
        );

        return response()->json(['data' => new OrderResource($order)], 201);
    }
}

The controller now has one job: translate an HTTP request into an action call and return a response. Change the response format? Touch only the Resource. Change pricing logic? Touch only the Action. Change validation? Touch only the FormRequest.


🟠 Open/Closed Principle (OCP)

Software entities should be open for extension, closed for modification.

BEFORE - the if/else notification nightmare:

<?php
// app/Services/NotificationService.php

declare(strict_types=1);

namespace App\Services;

use App\Models\User;

class NotificationService
{
    public function sendOrderConfirmation(User $user, array $orderData): void
    {
        if ($user->notification_channel === 'email') {
            \Mail::to($user->email)->send(new \App\Mail\OrderConfirmation($orderData));
        } elseif ($user->notification_channel === 'sms') {
            \Twilio::message($user->phone, 'Your order #' . $orderData['id'] . ' is confirmed.');
        } elseif ($user->notification_channel === 'push') {
            \Firebase::send($user->push_token, ['title' => 'Order confirmed', 'body' => '...']);
        }
        // Adding Slack? WhatsApp? You modify this class every time.
    }
}

Every new channel requires opening this class and modifying it - violating OCP and introducing regression risk.

AFTER - interface + implementations:

<?php
// app/Contracts/NotificationChannel.php

declare(strict_types=1);

namespace App\Contracts;

use App\Models\User;

interface NotificationChannel
{
    public function send(User $user, string $subject, string $message): void;

    public function supports(string $channelName): bool;
}
<?php
// app/Notifications/Channels/EmailChannel.php

declare(strict_types=1);

namespace App\Notifications\Channels;

use App\Contracts\NotificationChannel;
use App\Mail\GenericNotificationMail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;

class EmailChannel implements NotificationChannel
{
    public function send(User $user, string $subject, string $message): void
    {
        Mail::to($user->email)->send(new GenericNotificationMail($subject, $message));
    }

    public function supports(string $channelName): bool
    {
        return $channelName === 'email';
    }
}
<?php
// app/Notifications/Channels/SmsChannel.php

declare(strict_types=1);

namespace App\Notifications\Channels;

use App\Contracts\NotificationChannel;
use App\Models\User;
use Twilio\Rest\Client;

class SmsChannel implements NotificationChannel
{
    public function __construct(private readonly Client $twilio) {}

    public function send(User $user, string $subject, string $message): void
    {
        $this->twilio->messages->create($user->phone, [
            'from' => config('services.twilio.from'),
            'body' => $message,
        ]);
    }

    public function supports(string $channelName): bool
    {
        return $channelName === 'sms';
    }
}
<?php
// app/Services/NotificationService.php

declare(strict_types=1);

namespace App\Services;

use App\Contracts\NotificationChannel;
use App\Models\User;
use RuntimeException;

class NotificationService
{
    /** @param NotificationChannel[] $channels */
    public function __construct(private readonly array $channels) {}

    public function sendOrderConfirmation(User $user, array $orderData): void
    {
        $channel = $this->resolveChannel($user->notification_channel);
        $channel->send(
            $user,
            'Order Confirmed',
            "Your order #{$orderData['id']} has been confirmed."
        );
    }

    private function resolveChannel(string $name): NotificationChannel
    {
        foreach ($this->channels as $channel) {
            if ($channel->supports($name)) {
                return $channel;
            }
        }

        throw new RuntimeException("No handler for notification channel: {$name}");
    }
}

Adding a new Slack channel now means creating SlackChannel implements NotificationChannel and registering it - zero changes to existing code.


🟡 Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering the correctness of the program.

BEFORE - a subclass that breaks the contract:

<?php
// app/Services/PaymentProcessor.php

declare(strict_types=1);

namespace App\Services;

class PaymentProcessor
{
    public function charge(int $amountCents, string $currency): array
    {
        // Returns ['transaction_id' => '...', 'status' => 'success']
        return ['transaction_id' => 'txn_123', 'status' => 'success'];
    }
}

class FreeTrialPaymentProcessor extends PaymentProcessor
{
    public function charge(int $amountCents, string $currency): bool
    {
        // LSP VIOLATION: changed return type - callers expecting array will break
        return true;
    }
}

class RefundOnlyProcessor extends PaymentProcessor
{
    public function charge(int $amountCents, string $currency): array
    {
        // LSP VIOLATION: throws an exception the parent never promised
        throw new \BadMethodCallException('This processor does not support charges.');
    }
}

Any code that holds a reference to PaymentProcessor and calls charge() will break when given a FreeTrialPaymentProcessor or RefundOnlyProcessor.

AFTER - proper hierarchy through interfaces:

<?php
// app/Contracts/ChargeableGateway.php

declare(strict_types=1);

namespace App\Contracts;

use App\ValueObjects\ChargeResult;

interface ChargeableGateway
{
    public function charge(int $amountCents, string $currency): ChargeResult;
}

interface RefundableGateway
{
    public function refund(string $transactionId, int $amountCents): ChargeResult;
}
<?php
// app/ValueObjects/ChargeResult.php

declare(strict_types=1);

namespace App\ValueObjects;

final readonly class ChargeResult
{
    public function __construct(
        public readonly string $transactionId,
        public readonly string $status,
        public readonly bool   $success,
    ) {}
}
<?php
// app/Services/Gateways/FreeTrialGateway.php

declare(strict_types=1);

namespace App\Services\Gateways;

use App\Contracts\ChargeableGateway;
use App\ValueObjects\ChargeResult;

class FreeTrialGateway implements ChargeableGateway
{
    public function charge(int $amountCents, string $currency): ChargeResult
    {
        return new ChargeResult('free_trial_' . uniqid(), 'success', true);
    }
}

Now every implementation honours the same contract. Swap any gateway for another - callers never break.


🟢 Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use.

BEFORE - a bloated repository interface:

<?php
// app/Contracts/UserRepository.php

declare(strict_types=1);

namespace App\Contracts;

use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

interface UserRepository
{
    public function findById(int $id): ?User;
    public function findByEmail(string $email): ?User;
    public function findByApiToken(string $token): ?User;
    public function save(User $user): void;
    public function delete(int $id): void;
    public function paginate(int $perPage): LengthAwarePaginator;
    public function countByRole(string $role): int;
    public function bulkUpdateStatus(array $ids, string $status): int;
    public function exportToCsv(): string;
    public function importFromCsv(string $path): int;
}

An AuthService that only needs findByEmail is forced to depend on an interface dragging along exportToCsv and bulkUpdateStatus. Any class implementing this interface must provide all ten methods, even if most return throw new NotImplementedException.

AFTER - small, focused interfaces:

<?php
// app/Contracts/Users/CanFindUser.php

declare(strict_types=1);

namespace App\Contracts\Users;

use App\Models\User;

interface CanFindUser
{
    public function findById(int $id): ?User;
    public function findByEmail(string $email): ?User;
}
<?php
// app/Contracts/Users/CanPersistUser.php

declare(strict_types=1);

namespace App\Contracts\Users;

use App\Models\User;

interface CanPersistUser
{
    public function save(User $user): void;
    public function delete(int $id): void;
}
<?php
// app/Contracts/Users/CanQueryUsers.php

declare(strict_types=1);

namespace App\Contracts\Users;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;

interface CanQueryUsers
{
    public function paginate(int $perPage): LengthAwarePaginator;
    public function countByRole(string $role): int;
}
<?php
// app/Repositories/EloquentUserRepository.php

declare(strict_types=1);

namespace App\Repositories;

use App\Contracts\Users\CanFindUser;
use App\Contracts\Users\CanPersistUser;
use App\Contracts\Users\CanQueryUsers;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

class EloquentUserRepository implements CanFindUser, CanPersistUser, CanQueryUsers
{
    public function findById(int $id): ?User
    {
        return User::find($id);
    }

    public function findByEmail(string $email): ?User
    {
        return User::where('email', $email)->first();
    }

    public function save(User $user): void
    {
        $user->save();
    }

    public function delete(int $id): void
    {
        User::destroy($id);
    }

    public function paginate(int $perPage): LengthAwarePaginator
    {
        return User::paginate($perPage);
    }

    public function countByRole(string $role): int
    {
        return User::role($role)->count();
    }
}
<?php
// app/Services/AuthService.php

declare(strict_types=1);

namespace App\Services;

use App\Contracts\Users\CanFindUser;

// AuthService only depends on what it actually uses
class AuthService
{
    public function __construct(private readonly CanFindUser $users) {}

    public function attemptByEmail(string $email, string $password): bool
    {
        $user = $this->users->findByEmail($email);
        // ...
        return false;
    }
}

🔵 Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

BEFORE - hardcoded concrete dependency:

<?php
// app/Actions/Payments/ChargeCustomerAction.php

declare(strict_types=1);

namespace App\Actions\Payments;

use App\Services\Gateways\StripeGateway;

class ChargeCustomerAction
{
    public function execute(int $userId, int $amountCents): array
    {
        // DIP VIOLATION: depends directly on StripeGateway
        $gateway = new StripeGateway(config('services.stripe.secret'));

        return $gateway->charge($amountCents, 'usd');
    }
}

Testing this requires a real Stripe connection or fragile mocking of new keyword. Swapping to PayPal means editing this class.

AFTER - depend on an abstraction, inject the concrete:

<?php
// app/Contracts/PaymentGateway.php

declare(strict_types=1);

namespace App\Contracts;

use App\ValueObjects\ChargeResult;

interface PaymentGateway
{
    public function charge(int $amountCents, string $currency): ChargeResult;

    public function refund(string $transactionId, int $amountCents): ChargeResult;
}
<?php
// app/Services/Gateways/StripeGateway.php

declare(strict_types=1);

namespace App\Services\Gateways;

use App\Contracts\PaymentGateway;
use App\ValueObjects\ChargeResult;
use Stripe\StripeClient;

class StripeGateway implements PaymentGateway
{
    public function __construct(private readonly StripeClient $client) {}

    public function charge(int $amountCents, string $currency): ChargeResult
    {
        $intent = $this->client->paymentIntents->create([
            'amount'   => $amountCents,
            'currency' => $currency,
        ]);

        return new ChargeResult($intent->id, $intent->status, $intent->status === 'succeeded');
    }

    public function refund(string $transactionId, int $amountCents): ChargeResult
    {
        $refund = $this->client->refunds->create([
            'payment_intent' => $transactionId,
            'amount'         => $amountCents,
        ]);

        return new ChargeResult($refund->id, $refund->status, $refund->status === 'succeeded');
    }
}
<?php
// app/Actions/Payments/ChargeCustomerAction.php

declare(strict_types=1);

namespace App\Actions\Payments;

use App\Contracts\PaymentGateway;
use App\Models\Payment;
use App\ValueObjects\ChargeResult;

class ChargeCustomerAction
{
    public function __construct(private readonly PaymentGateway $gateway) {}

    public function execute(int $userId, int $amountCents, string $currency = 'usd'): ChargeResult
    {
        $result = $this->gateway->charge($amountCents, $currency);

        Payment::create([
            'user_id'        => $userId,
            'amount_cents'   => $amountCents,
            'transaction_id' => $result->transactionId,
            'status'         => $result->status,
        ]);

        return $result;
    }
}
<?php
// app/Providers/PaymentServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use App\Contracts\PaymentGateway;
use App\Services\Gateways\StripeGateway;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(PaymentGateway::class, function (): StripeGateway {
            return new StripeGateway(
                new StripeClient(config('services.stripe.secret'))
            );
        });
    }
}

In tests you bind a FakePaymentGateway or use $this->mock(PaymentGateway::class). In production you swap to PayPalGateway by changing a single line in the provider.


🏗️ How Laravel's Container Enables SOLID

Laravel's service container is the engine that makes SOLID practical rather than aspirational in PHP. Here is how each part maps:

Automatic resolution (autowiring) - When you type-hint an interface in a constructor, the container resolves the concrete class bound to that interface. No manual wiring per-class.

Singleton vs transient bindings - singleton() shares one instance throughout a request. bind() creates a new instance each resolution. Choosing correctly is part of SRP: a stateless service should often be a singleton; a stateful builder should be transient.

Contextual binding - When two classes need the same interface but different implementations:

<?php
// app/Providers/AppServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use App\Actions\Payments\ChargeCustomerAction;
use App\Actions\Subscriptions\ChargeSubscriptionAction;
use App\Contracts\PaymentGateway;
use App\Services\Gateways\PayPalGateway;
use App\Services\Gateways\StripeGateway;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app
            ->when(ChargeCustomerAction::class)
            ->needs(PaymentGateway::class)
            ->give(StripeGateway::class);

        $this->app
            ->when(ChargeSubscriptionAction::class)
            ->needs(PaymentGateway::class)
            ->give(PayPalGateway::class);
    }
}

This is OCP and DIP enforced at the container level with zero if/else in business logic.


⚠️ Common SOLID Violations in Laravel Projects

1. Jobs that do everything. A ProcessOrder job that validates, charges, sends email, updates inventory, and fires analytics events is an SRP violation at the queue layer. Split into ChargeOrderJob, SendConfirmationJob, chained or dispatched from an orchestrating action.

2. Eloquent models with business logic. Adding calculateDiscount(), sendWelcomeEmail(), and generateInvoicePdf() to the User model gives the model four reasons to change. Models should handle attribute access, relationships, and casts - nothing more.

3. Traits as shared inheritance. PHP traits are copy-paste at compile time. Dumping query logic in a HasFilters trait that gets used across 15 models is an ISP violation - every model inherits every filter method even when it uses two.

4. Facades hiding dependencies. Cache::get() and Mail::send() inside methods make those methods untestable without Facade::fake() and hide the dependency graph. Inject CacheRepository and Mailer into constructors so dependencies are explicit and testable.

5. Config arrays as pseudo-strategy. A config/payments.php with conditional logic checking APP_ENV to decide which class to instantiate is OCP-violating configuration masquerading as strategy. Use contextual binding or tagged services.


🤔 When NOT to Force SOLID

SOLID is a set of design goals, not commandments. Blind application creates its own problems:

Premature abstraction kills velocity. Building a full interface-plus-implementation for a notification system that will only ever send email adds indirection without benefit. Add the abstraction when the second implementation appears.

Simple CRUD does not need actions. A TagController that lists, creates, updates, and deletes tags with zero business logic does not benefit from an UpdateTagAction. Keep it in the controller. SRP at the framework layer (FormRequest + Resource) is enough.

Over-segregated interfaces become noise. If your interface has one method, ask whether an interface is even needed. A CanCalculateTax interface with a single calculate() method used in one place is indirection without a consumer. A closure or a concrete service class is cleaner.

Testability is the north star. Apply SOLID when it makes a class easier to test in isolation or easier to extend without regression risk. If neither is true for your use case, skip it. The goal is maintainable, shippable software - not architectural purity.


  • SOLID is a vocabulary for describing coupling problems, not a recipe to follow mechanically
  • SRP is violated most often in controllers and jobs - FormRequests, Actions, and Resources solve it cleanly in Laravel
  • OCP and DIP are best implemented together: define an interface, bind the concrete in a service provider, inject the interface everywhere else
  • LSP breaks when subclasses change return types or throw exceptions the parent never declared - enforce contracts with interfaces, not inheritance
  • ISP keeps your test doubles small and your constructors honest - only depend on what you actually call
  • Laravel's service container makes DIP nearly free: type-hint an interface and let the container wire it
  • Apply SOLID when it reduces coupling or improves testability; skip it when it adds indirection without benefit

Follow me on LinkedIn for more Laravel tips! Would you like to learn more about applying these principles in a real DDD Laravel project? Let me know in the comments below!

Comments (0)
Leave a comment

© 2026 All rights reserved.