Refactor Story: A 400-Line Controller to Something Testable

2026-09-107 min readTwixr Solutions

Cover image for Refactor Story: A 400-Line Controller to Something Testable

Your controller has 400 lines. You know it's wrong. You just don't know where to start.

That's the actual problem. Not the length. The length is a symptom. The real issue is that business logic, data access, validation, and HTTP concerns are all tangled together in one class. You can't test any piece of it without triggering all of it.

This is a before/after walkthrough of a refactor I've done repeatedly on production codebases. Laravel examples throughout, but the pattern applies to NestJS or any MVC framework.


What a 400-Line Controller Actually Looks Like

Before we fix anything, be honest about what's in there. A typical offender has:

  • Direct DB:: or Eloquent calls scattered through the method body
  • Inline validation with $request->validate([...])
  • Conditional branches that should be policy or guard logic
  • Email sending, PDF generation, or third-party API calls inline
  • try/catch blocks wrapping unrelated concerns
  • Methods that are 80-100 lines each

Here's a stripped-down version of what that store() method looks like before the refactor:

// OrderController.php (before)
public function store(Request $request)
{
    $request->validate([
        'product_id' => 'required|exists:products,id',
        'quantity'   => 'required|integer|min:1',
        'coupon'     => 'nullable|string',
    ]);

    $product = Product::findOrFail($request->product_id);

    if ($product->stock < $request->quantity) {
        return response()->json(['error' => 'Insufficient stock'], 422);
    }

    $price = $product->price * $request->quantity;

    if ($request->coupon) {
        $coupon = Coupon::where('code', $request->coupon)->first();
        if (!$coupon || $coupon->expired_at < now()) {
            return response()->json(['error' => 'Invalid coupon'], 422);
        }
        $price -= ($price * $coupon->discount_percent / 100);
    }

    $order = Order::create([
        'user_id'    => auth()->id(),
        'product_id' => $product->id,
        'quantity'   => $request->quantity,
        'total'      => $price,
        'status'     => 'pending',
    ]);

    // send email
    Mail::to(auth()->user()->email)->send(new OrderPlaced($order));

    // update stock
    $product->decrement('stock', $request->quantity);

    // log to Slack
    Http::post(config('services.slack.webhook'), [
        'text' => "New order #{$order->id}",
    ]);

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

One method. Three responsibilities. Zero testability without booting the full HTTP stack.


Before/after diagram showing a monolithic controller split into form request, service class, and event listener layers


The Refactor: Three Moves

You do not need to rewrite everything at once. These three moves, applied in order, get you to a testable state without breaking production.

Move 1 - Extract Validation Into a Form Request

Pull the validate() call into a dedicated StoreOrderRequest. This is built into Laravel and costs almost nothing.

// App/Http/Requests/StoreOrderRequest.php (after)
class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'product_id' => ['required', 'exists:products,id'],
            'quantity'   => ['required', 'integer', 'min:1'],
            'coupon'     => ['nullable', 'string'],
        ];
    }
}

Now you can unit-test validation rules in isolation. You can mock the form request in controller tests. The controller method signature becomes store(StoreOrderRequest $request) - one line, one responsibility.

Move 2 - Extract Business Logic Into a Service Class

The price calculation, coupon application, and stock check are business rules. They belong in a service, not an HTTP handler.

// App/Services/OrderService.php (after)
class OrderService
{
    public function placeOrder(User $user, Product $product, int $qty, ?string $couponCode): Order
    {
        if ($product->stock < $qty) {
            throw new InsufficientStockException();
        }

        $price = $this->applyDiscount($product->price * $qty, $couponCode);

        $order = Order::create([
            'user_id'    => $user->id,
            'product_id' => $product->id,
            'quantity'   => $qty,
            'total'      => $price,
            'status'     => 'pending',
        ]);

        $product->decrement('stock', $qty);

        return $order;
    }

    private function applyDiscount(float $subtotal, ?string $couponCode): float
    {
        if (!$couponCode) return $subtotal;

        $coupon = Coupon::where('code', $couponCode)
                        ->where('expired_at', '>', now())
                        ->first();

        if (!$coupon) throw new InvalidCouponException();

        return $subtotal - ($subtotal * $coupon->discount_percent / 100);
    }
}

Now placeOrder() takes plain PHP arguments. You can test it with a real SQLite database or a mock repository - no HTTP, no session, no middleware.

Move 3 - Move Side Effects Into Events or Jobs

Email sending and Slack notifications are side effects. They should not live inside the service either. Dispatch an event and let a listener handle them.

// After order creation in OrderService
event(new OrderPlaced($order));
// App/Listeners/SendOrderNotifications.php
class SendOrderNotifications
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user->email)->send(new OrderPlacedMail($event->order));

        Http::post(config('services.slack.webhook'), [
            'text' => "New order #{$event->order->id}",
        ]);
    }
}

You can now test OrderService without touching the mail or Slack. You can test SendOrderNotifications with a fake event. The pieces are independent.


What the Controller Looks Like After

// OrderController.php (after)
class OrderController extends Controller
{
    public function __construct(private OrderService $orderService) {}

    public function store(StoreOrderRequest $request): JsonResponse
    {
        $product = Product::findOrFail($request->product_id);

        $order = $this->orderService->placeOrder(
            auth()->user(),
            $product,
            $request->quantity,
            $request->coupon,
        );

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

12 lines. One job: coordinate the HTTP layer with the service layer. That is what a controller is for.


Code card showing the 80-line store() method before and the 12-line version after, dark background with syntax highlighting, watermarked twixrsolutions.com


What You Can Now Test (and How)

With the refactor done, you have three independently testable units:

Form request - unit test

  • Does it reject missing product_id?
  • Does it accept a null coupon?
  • No database, no HTTP stack needed.

OrderService - feature test with SQLite

  • Does it throw InsufficientStockException when stock is 0?
  • Does it correctly apply a 20% discount?
  • Does it decrement stock by the ordered quantity?
  • Does it fire the OrderPlaced event? (use Event::fake())

Controller - HTTP test

  • Does it return 201 on valid input?
  • Does it return 422 on validation failure?
  • Mock OrderService here; you already trust it from the service tests.

You went from one 80-line method that requires a full application boot to test, to three classes each testable in under 200ms.


Common Objections

"This is more files." Yes. Files are not the cost. Complexity is the cost. More files with clear responsibilities is easier to navigate than fewer files with tangled ones.

"The service is still hitting the database." Fine for most cases. If you want to go further, introduce a repository interface and inject it. That is a fourth move, not a first move. Do not over-abstract on day one.

"My controller has 20 methods, not one." Pick the single worst method. Refactor it. Ship. Then the next one. Incremental is fine. Big-bang rewrites on live codebases lose.


Architecture diagram showing the dependency flow from HTTP Request to Form Request to Controller to Service to Event to Listener, clean layered layout with labeled arrows


What This Refactor Actually Buys You

  • Any engineer on the team can add a unit test for the coupon logic without understanding the HTTP layer.
  • You can swap the Slack notification for a different provider by touching one listener, not one 400-line file.
  • CI runs in seconds instead of minutes because service tests do not boot the full app.
  • Onboarding a new dev takes 30 minutes instead of a week - the code tells them where things belong.

The controller length was never the problem. Untestable coupling was. These three moves - form request, service class, events for side effects - break the coupling without rewriting the world.

Start with the worst method. Not the whole file.

Frequently asked questions

One method at a time. Pick the most complex or most bug-prone method, extract it, write tests, ship it. Then move to the next. Big-bang rewrites on production codebases introduce risk without proportional benefit. Incremental refactoring keeps the app running and builds test coverage gradually.

Enjoyed this article?

Get notified when I publish new posts on SaaS, Laravel, and remote engineering.

Keep reading

More posts

2026-08-277 min read

Model Observers vs Events vs Listeners in Laravel: Pick the Right Tool

Model observers, events, and listeners all hook into your app's lifecycle - but using the wrong one creates tangled, untestable code. Here's how to choose.

Read

August 26, 20267 min read

Queued Jobs vs Scheduled Commands in Laravel - When Each One Actually Fits

Picking the wrong tool slows your app and burns debugging hours. Here's a practical breakdown of when to reach for a queued job versus a scheduled command in La

Read

2026-08-297 min read

Overusing `with()` and Missing the Real N+1 in Laravel

You added with() everywhere and thought you were done. But N+1 queries are still wrecking your Laravel app. Here's where they actually hide.

Read