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
- Testability —
OrderServicecan be unit tested with a fakePaymentGatewayInterfaceimplementation, 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.