HomeBlogRefactoring Spaghetti PHP: How to Decouple...

Engineering Guide · 9 min read · October 8, 2026

Refactoring Spaghetti PHP: How to Decouple a Legacy Monolith into Clean Domain Services

Every mature PHP codebase eventually suffers from bloated controllers, hidden side effects, and tightly coupled database queries. Here is how to refactor spaghetti code into single-responsibility Action classes, domain services, and modular components without breaking production.

Author
Smit Desai
Published
October 8, 2026
Read time
9 min read
Topics
FDSE · Full Stack · Hiring Strategy · Architecture
Pillars
FDSE Guide · Full Stack Services

In the early stages of a web application, speed of delivery trumps architectural purity. Developers write SQL queries directly in controllers, mix validation rules with business logic, and fire third-party API requests inline. It works, and the business grows.

Fast forward five years: your OrderController.php is now 2,400 lines long. Making a single change to the discount calculation breaks invoice generation. Writing automated tests feels impossible because every method depends on global state and session variables. The team is terrified of deploying changes on Fridays.

You don't need to rewrite your entire system to fix this.

Here is the exact step-by-step refactoring blueprint I use to take tangled PHP monoliths and transform them into clean, testable, modular domain architecture. If your team is fighting unmaintainable code, explore our Legacy Software Refactoring Services.

Refactoring a Monolithic PHP Application to Clean Modular Architecture


1. The Anatomy of Spaghetti Code (What Needs to Go)

Consider this typical legacy controller method:

// THE ANTI-PATTERN: Bloated, untestable, tightly coupled
class OrderController extends Controller
{
    public function store(Request $request)
    {
        // 1. Manual inline validation
        if (!$request->has('user_id') || $request->input('total') <= 0) {
            return response()->json(['error' => 'Invalid data'], 400);
        }

        // 2. Direct business logic & calculations
        $discount = 0;
        if ($request->input('coupon') === 'SUMMER20') {
            $discount = $request->input('total') * 0.20;
        }
        $finalTotal = $request->input('total') - $discount;

        // 3. Database mutation
        $order = new Order();
        $order->user_id = $request->input('user_id');
        $order->total = $finalTotal;
        $order->save();

        // 4. Inline third-party API integration
        $stripe = new \Stripe\StripeClient(env('STRIPE_SECRET'));
        $stripe->charges->create([
            'amount' => $finalTotal * 100,
            'currency' => 'usd',
            'source' => $request->input('stripeToken'),
        ]);

        // 5. Direct email sending
        \Mail::raw("Your order #{$order->id} is confirmed!", function($msg) use ($request) {
            $msg->to($request->input('email'))->subject('Order Confirmation');
        });

        return response()->json($order);
    }
}

Why this code fails at scale:

  • You cannot reuse the order creation logic from a CLI command, queued worker, or mobile API.
  • Testing this method requires hitting a real database, mocking a live Stripe API, and preventing real emails from sending.
  • It violates every tenet of the Single Responsibility Principle.

2. Step 1: Extract Validation to Form Requests

The controller shouldn't care about HTTP input sanitation. Extract validation into a dedicated FormRequest:

namespace App\Http\Requests\Orders;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'user_id' => ['required', 'exists:users,id'],
            'total' => ['required', 'numeric', 'min:1'],
            'coupon' => ['nullable', 'string'],
            'payment_token' => ['required', 'string'],
        ];
    }
}

3. Step 2: The Domain Action Class (Single Responsibility)

Instead of bloated service classes with 30 methods, extract the business logic into a single, focused Domain Action class.

An Action class:

  • Does exactly one thing.
  • Accepts strictly typed inputs (or a Data Transfer Object).
  • Can be injected anywhere (HTTP controller, Artisan command, Queue Job).
  • Can be tested in isolation using fast unit tests.
namespace App\Domain\Orders\Actions;

use App\Domain\Orders\Data\OrderData;
use App\Models\Order;
use App\Domain\Orders\Events\OrderCreated;
use Illuminate\Support\Facades\DB;

class CreateOrderAction
{
    public function __construct(
        private CalculateOrderDiscountAction $discountCalculator,
        private ProcessPaymentAction $paymentProcessor,
    ) {}

    public function execute(OrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            // 1. Calculate final pricing
            $discount = $this->discountCalculator->execute($data->total, $data->coupon);
            $finalTotal = $data->total - $discount;

            // 2. Persist order
            $order = Order::create([
                'user_id' => $data->userId,
                'total' => $finalTotal,
                'discount' => $discount,
                'status' => 'pending',
            ]);

            // 3. Process payment through payment gateway
            $this->paymentProcessor->execute($order, $data->paymentToken);

            // 4. Dispatch domain event (asynchronous side effects)
            event(new OrderCreated($order));

            return $order;
        });
    }
}

4. Step 3: Decouple Side Effects with Domain Events

Sending emails, syncing with CRM systems (HubSpot, Salesforce), and updating analytics dashboards are side effects. They should never block the HTTP request or cause the core order transaction to fail if an external email service times out.

Dispatch a clean event: event(new OrderCreated($order));.

Attach asynchronous queue listeners in EventServiceProvider:

protected $listen = [
    OrderCreated::class => [
        SendOrderConfirmationEmailListener::class,
        SyncOrderToAccountingSoftwareListener::class,
        DispatchInventoryFulfillmentListener::class,
    ],
];

Each listener runs independently in a background Redis queue. If the accounting API is down, it retries automatically without affecting the customer's checkout experience.


5. Step 4: The Clean Controller (Less than 15 Lines)

With Form Requests, Action classes, and Events in place, your controller becomes an elegant, lightweight traffic director:

namespace App\Http\Controllers\Api\V1;

use App\Http\Requests\Orders\StoreOrderRequest;
use App\Domain\Orders\Actions\CreateOrderAction;
use App\Domain\Orders\Data\OrderData;
use App\Http\Resources\Api\V1\OrderResource;

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

        return new OrderResource($order);
    }
}

6. The Long-Term Benefits of Modular Architecture

  1. Velocity: New developers understand features in hours because code is organized by domain (Domain/Orders, Domain/Billing) rather than buried in 2,000-line controller files.
  2. Effortless Testing: You can write unit tests for CreateOrderAction in milliseconds without spinning up a headless browser or mocking HTTP kernels.
  3. Reusability: Need to create an order from a scheduled nightly cron job or an incoming Slack webhook? Simply call $createOrderAction->execute($data).

Untangle Your Legacy Codebase with Senior Engineering

Refactoring legacy technical debt requires a steady hand, extensive experience, and zero disruption to your daily operations.

Learn how we help teams modernize aging codebases through our Legacy Software Upgrade Services or bring in on-demand senior expertise on an Hourly Developer basis. Schedule a technical audit to evaluate your codebase today.

Next Steps · Relevant Pillar Pages

Pillar 1 · Strategic Deployment

Forward Deployed Software Engineer

Directly embed an engineer to unpack ambiguous bottlenecks, integrate legacy systems, and ship customer-facing production code.

Pillar 2 · Full Lifecycle Engineering

Full Stack Developer Services

End-to-end full stack development across Laravel, PHP, Python, modern frontends, high-performance APIs, and server infrastructure.

01 — Frequently asked questions

about FDSE vs Full Stack

Why is 'Fat Models, Thin Controllers' considered an outdated pattern in modern Laravel?

While Fat Models keep controllers light, they often result in bloated 3,000-line Eloquent models packed with business logic, validation, event triggers, and database helpers. This violates the Single Responsibility Principle and makes models nearly impossible to unit test or maintain.

What is the Single Action Controller pattern?

Instead of writing massive controllers with 10 CRUD methods, you write dedicated, invokable controller classes (__invoke) that delegate request execution to a dedicated Domain Action class. Each endpoint has exactly one focused responsibility.

How do Domain Action classes differ from Service classes?

A traditional Service class (e.g. OrderService) often becomes a dumping ground for 20 unrelated methods. An Action class (e.g. CreateOrderAction) performs exactly one discrete task, takes typed parameters, executes domain logic, and returns a predictable result.

Can this refactoring be done gradually while continuing to ship features?

Yes. You don't need to rewrite the whole application at once. As new features are requested or bugs are touched, extract those specific code paths into Action classes, gradually increasing code health over time.

03 — Have an engineering need?

hire the right expertise

Let's talk tech.

Deciding between an embedded forward deployed engineer or a senior full stack developer? Share your technical context and timeline.

Solitaire Corporate Park, Makarba, Ahmedabad, Gujarat 380015, India · IST (UTC+5:30) · --:-- IST · Mon–Fri 09:00–18:00 IST · US & EU overlap daily

No newsletter, no CRM. Just a reply.