Laravel Monoliths Done Right: Applying SOLID Principles with a Practical Example

Nawjesh Soyeb Architecture Best Practices Design Patterns Laravel Monolith SOLID

Monolith gets treated as a dirty word, but for most Laravel apps it's the right architecture — the real problem is a monolith with no internal structure. This post walks through applying SOLID principles with a full before-and-after example: refactoring a 60-line fat checkout controller handling Stripe and PayPal directly into clean, testable, extendable service classes — without touching your deployment model at all.

"Monolith" gets treated as a dirty word, usually by people copying architecture decisions from companies with a thousand engineers. For the vast majority of projects — including most Laravel apps in production — a monolith is the right choice. The actual problem people run into isn't the monolith itself, it's a monolith with no internal structure. SOLID principles are how you keep a single Laravel codebase maintainable as it grows, without needing microservices to do it.

Monolith Isn't the Problem — Disorganization Is

A monolith means your whole application (web, API, admin, jobs) ships and deploys as one codebase. Laravel is built around this by default, and it's genuinely a good default:

  • One codebase, one deployment, one database connection to reason about
  • No network latency or distributed-systems complexity between "services" that are really just your own app talking to itself
  • Easier local development, testing, and debugging

Where monoliths get a bad reputation is when they turn into what's often called a "big ball of mud" — controllers that are 400 lines long, business logic tangled with HTTP concerns, and every change risking something unrelated breaking. SOLID principles exist to prevent exactly that, inside a monolith, without changing your deployment model at all.

SOLID, Briefly

  • S — Single Responsibility: a class should have one reason to change
  • O — Open/Closed: open for extension, closed for modification — add new behavior without editing existing tested code
  • L — Liskov Substitution: a subclass should be usable anywhere its parent is expected, without breaking behavior
  • I — Interface Segregation: don't force a class to depend on methods it doesn't use
  • D — Dependency Inversion: depend on abstractions (interfaces), not concrete classes

These aren't Laravel-specific, but Laravel's container and interface binding make them easy to apply without ceremony.

The Practical Example: Checkout with Multiple Payment Methods

This is one of the most common places SOLID violations creep into real Laravel apps.

Before: The Fat Controller

class CheckoutController extends Controller
{
    public function process(Request $request)
    {
        $order = Order::create([
            'user_id' => $request->user()->id,
            'total' => $request->total,
        ]);

        if ($request->payment_method === 'stripe') {
            // 30 lines of Stripe SDK calls directly here
            $stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
            $charge = $stripe->charges->create([
                'amount' => $request->total * 100,
                'currency' => 'usd',
                'source' => $request->stripe_token,
            ]);
            $order->update(['status' => 'paid', 'transaction_id' => $charge->id]);
        } elseif ($request->payment_method === 'paypal') {
            // 30 more lines of PayPal SDK calls directly here
            // ...
            $order->update(['status' => 'paid']);
        }

        Mail::to($request->user())->send(new OrderConfirmation($order));

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

Problems this creates:

  • The controller has multiple reasons to change: order creation logic, Stripe integration, PayPal integration, and email sending all live in one method
  • Adding a third payment method means editing this method again and risking the existing two
  • Impossible to unit test payment logic without hitting the real Stripe/PayPal SDKs or mocking HTTP requests inside a controller test
  • Violates Single Responsibility, Open/Closed, and Dependency Inversion simultaneously

After: Applying SOLID

Step 1 — Define an interface (Dependency Inversion + Interface Segregation)

// app/Contracts/PaymentGatewayInterface.php
interface PaymentGatewayInterface
{
    public function charge(Order $order, array $paymentData): PaymentResult;
}

Step 2 — One class per gateway, each with a single responsibility

// app/Services/Payments/StripeGateway.php
class StripeGateway implements PaymentGatewayInterface
{
    public function __construct(private \Stripe\StripeClient $stripe) {}

    public function charge(Order $order, array $paymentData): PaymentResult
    {
        $charge = $this->stripe->charges->create([
            'amount' => $order->total * 100,
            'currency' => 'usd',
            'source' => $paymentData['stripe_token'],
        ]);

        return new PaymentResult(success: true, transactionId: $charge->id);
    }
}
// app/Services/Payments/PaypalGateway.php
class PaypalGateway implements PaymentGatewayInterface
{
    public function charge(Order $order, array $paymentData): PaymentResult
    {
        // PayPal SDK logic here, isolated from everything else
        return new PaymentResult(success: true, transactionId: $response->id);
    }
}

Because both implement the same interface, either one can be swapped in anywhere a PaymentGatewayInterface is expected — this is Liskov Substitution in practice.

Step 3 — Adding a new gateway means adding a class, not editing old ones (Open/Closed)

// app/Services/Payments/RazorpayGateway.php
class RazorpayGateway implements PaymentGatewayInterface
{
    public function charge(Order $order, array $paymentData): PaymentResult
    {
        // New gateway, zero changes to Stripe/PayPal classes or the controller
    }
}

Step 4 — Bind the right implementation via the container

// app/Providers/AppServiceProvider.php
public function register()
{
    $this->app->bind(PaymentGatewayInterface::class, function ($app) {
        return match (request('payment_method')) {
            'stripe' => $app->make(StripeGateway::class),
            'paypal' => $app->make(PaypalGateway::class),
            'razorpay' => $app->make(RazorpayGateway::class),
        };
    });
}

Step 5 — A dedicated service handles order creation (Single Responsibility)

// app/Services/OrderService.php
class OrderService
{
    public function __construct(private PaymentGatewayInterface $gateway) {}

    public function checkout(User $user, float $total, array $paymentData): Order
    {
        $order = Order::create([
            'user_id' => $user->id,
            'total' => $total,
            'status' => 'pending',
        ]);

        $result = $this->gateway->charge($order, $paymentData);

        $order->update([
            'status' => $result->success ? 'paid' : 'failed',
            'transaction_id' => $result->transactionId,
        ]);

        return $order;
    }
}

Step 6 — The controller becomes thin again

class CheckoutController extends Controller
{
    public function process(Request $request, OrderService $orderService)
    {
        $order = $orderService->checkout(
            $request->user(),
            $request->total,
            $request->only(['stripe_token', 'paypal_data'])
        );

        Mail::to($request->user())->send(new OrderConfirmation($order));

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

What This Bought You

  • TestabilityOrderService can be unit tested with a fake PaymentGatewayInterface implementation, no real API calls needed
  • Safety — adding Razorpay never touched Stripe or PayPal code
  • Clarity — each class answers one question: what does this class do? Not three or four
  • Still a monolith — nothing here required splitting into microservices, separate deployments, or a message queue between "services." It's the same Laravel app, same deployment, same database — just internally organized.

When to Apply This (and When Not To)

Not every controller needs this treatment. A simple CRUD resource with one obvious way to do things doesn't need an interface and three classes — that's over-engineering in the other direction. Reach for SOLID structuring specifically when:

  • There's more than one way to do the same thing (multiple payment gateways, multiple notification channels, multiple export formats)
  • The logic is complex enough that testing it through HTTP requests is slow or awkward
  • You can already predict a second or third variant is coming

Quick Checklist

  • ✓ Business logic lives in service classes, not controllers
  • ✓ Anything with multiple implementations (gateways, providers, drivers) sits behind an interface
  • ✓ New variants are added as new classes, not by editing existing ones
  • ✓ Controllers only orchestrate — they call a service and return a response
  • ✓ Interfaces are bound in a service provider, not instantiated directly in code that uses them

A well-structured Laravel monolith with SOLID principles applied where it matters gives you almost everything people reach for microservices to get — testability, safe extension, clear ownership of logic — without the operational cost of running and coordinating multiple services.