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.

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
- 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. - Effortless Testing: You can write unit tests for
CreateOrderActionin milliseconds without spinning up a headless browser or mocking HTTP kernels. - 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.