Tech Verse Logo
Enable dark mode
Refactoring Laravel Fat Controllers Without Over-Engineering

Refactoring Laravel Fat Controllers Without Over-Engineering

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

You open a Laravel project and find an OrderController storing new orders in a 200-line method. It validates incoming parameters, handles file uploads, calculates discounts, wraps everything in a database transaction, calls Stripe, triggers two queued emails, and builds a custom JSON array for response. When a bug hits in production, tracking execution flow through that monstrosity costs you two hours of debugging.

The standard internet advice is often worse than the problem: "Build an OrderRepositoryInterface, an OrderRepository implementation, an OrderDataTransferObject, an OrderService, and an OrderTransformer." Suddenly, you have six files for one database write, and Eloquent's query builder becomes useless because it's hidden behind generic getters.

You don't need synthetic architecture patterns borrowed from Java to fix a fat controller in Laravel 12 running on PHP 8.3. You just need to push responsibilities into native framework abstractions that already exist.

The Bloated Controller Code

Here's what an overgrown controller method looks like before cleanup. It attempts to do every single task in a single execution pass during an HTTP request.

namespace App\Http\Controllers;

use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;

class OrderController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity' => 'required|integer|min:1',
            'stripe_token' => 'required|string',
            'shipping_address' => 'required|array',
            'shipping_address.street' => 'required|string',
            'shipping_address.city' => 'required|string',
            'shipping_address.zip' => 'required|string',
        ]);

        $product = Product::findOrFail($validated['product_id']);
        $totalAmount = $product->price * $validated['quantity'];

        DB::beginTransaction();

        try {
            $stripe = new StripeClient(config('services.stripe.secret'));
            $charge = $stripe->charges->create([
                'amount' => $totalAmount,
                'currency' => 'usd',
                'source' => $validated['stripe_token'],
                'description' => "Order for {$product->name}",
            ]);

            $order = Order::create([
                'user_id' => $request->user()->id,
                'product_id' => $product->id,
                'quantity' => $validated['quantity'],
                'total_amount' => $totalAmount,
                'stripe_charge_id' => $charge->id,
                'shipping_address' => json_encode($validated['shipping_address']),
                'status' => 'paid',
            ]);

            DB::commit();
        } catch (\Exception $e) {
            DB::rollBack();
            return response()->json(['error' => 'Payment failed: ' . $e->getMessage()], 422);
        }

        Mail::to($request->user())->send(new \App\Mail\OrderReceipt($order));

        return response()->json([
            'id' => $order->id,
            'status' => $order->status,
            'total' => $order->total_amount / 100,
        ], 201);
    }
}

This controller fails on multiple fronts. It couples HTTP logic directly to external APIs, manual database transactions, and mail rendering. If Stripe responds slowly, the entire request hangs. If you want to place an order from a CLI command or an API webhook later, you'll copy and paste this whole block.

Step 1: Extract Validation to a Form Request

PHP 8.3 gives us typed properties and strong array structure validation. Moving validation rules into a dedicated Form Request class instantly trims 15 lines from your controller and makes validation reusable for authorization check hooks.

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    public function rules(): array
    {
        return [
            'product_id' => ['required', 'exists:products,id'],
            'quantity' => ['required', 'integer', 'min:1'],
            'stripe_token' => ['required', 'string'],
            'shipping_address' => ['required', 'array'],
            'shipping_address.street' => ['required', 'string'],
            'shipping_address.city' => ['required', 'string'],
            'shipping_address.zip' => ['required', 'string'],
        ];
    }
}

When Laravel handles a controller parameter typed with StoreOrderRequest, it runs validation before the controller method executes. If validation fails, Laravel throws an exception that returns a formatted 422 JSON response or redirects back automatically. Your controller code never executes on invalid data.

Step 2: Isolate External Operations with Queued Jobs

Processing third-party HTTP calls and sending emails during the main HTTP request thread adds latency. A third-party network hiccup turns a 50ms request into a 10-second timeout. In Laravel 12, offloading slow operations to a background queue requires a simple job component.

We keep order record creation fast inside the main database step, while deferring mail delivery to asynchronous queue workers:

namespace App\Jobs;

use App\Models\Order;
use App\Mail\OrderReceipt;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;

class SendOrderReceiptJob implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Order $order
    ) {}

    public function handle(): void
    {
        Mail::to($this->order->user)->send(new OrderReceipt($this->order));
    }
}

Step 3: Extract Business Logic to Single-Action Classes

Avoid creating multi-layered service classes that contain dozens of unrelated methods like updateOrder, cancelOrder, and processRefund. They eventually become as fat as the controllers you are trying to clean up.

Instead, write a single-action class that exposes an __invoke method or a public handle method. This keeps each business operation isolated, easy to read, and simple to unit test.

namespace App\Actions;

use App\Jobs\SendOrderReceiptJob;
use App\Models\Order;
use App\Models\User;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Stripe\StripeClient;

class CreateOrderAction
{
    public function __construct(
        private readonly StripeClient $stripe
    ) {}

    public function handle(User $user, Product $product, array $data): Order
    {
        $totalAmount = $product->price * $data['quantity'];

        return DB::transaction(function () use ($user, $product, $data, $totalAmount) {
            $charge = $this->stripe->charges->create([
                'amount' => $totalAmount,
                'currency' => 'usd',
                'source' => $data['stripe_token'],
                'description' => "Order for {$product->name}",
            ]);

            $order = Order::create([
                'user_id' => $user->id,
                'product_id' => $product->id,
                'quantity' => $data['quantity'],
                'total_amount' => $totalAmount,
                'stripe_charge_id' => $charge->id,
                'shipping_address' => $data['shipping_address'],
                'status' => 'paid',
            ]);

            SendOrderReceiptJob::dispatch($order);

            return $order;
        });
    }
}

Notice how DB::transaction takes a callback here. If any exception happens during charge creation or database insertion, the transaction rolls back automatically without manual try/catch boilerplate or calls to DB::rollBack().

Step 4: Rebuild the Controller

With validation in a Form Request and business execution in an Action, the final controller method becomes small, clean, and declarative:

namespace App\Http\Controllers;

use App\Actions\CreateOrderAction;
use App\Http\Requests\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use App\Models\Product;

class OrderController extends Controller
{
    public function store(
        StoreOrderRequest $request,
        CreateOrderAction $createOrder
    ) {
        $product = Product::findOrFail($request->validated('product_id'));

        $order = $createOrder->handle(
            user: $request->user(),
            product: $product,
            data: $request->validated()
        );

        return new OrderResource($order);
    }
}

The controller now handles six lines of execution instead of sixty. It takes the validated HTTP payload, passes it to the action domain layer, and wraps the resulting Eloquent model in an API resource response.

Avoid the Repository Pattern Pitfall

Developers coming from other ecosystems often build repository interfaces over Eloquent models. In Laravel, Eloquent models are already an Active Record implementation built on top of an underlying Query Builder.

Adding an extra repository layer breaks Eloquent features like Model::shouldBeStrict(), relationship auto-eager loading, and scope chaining. Instead of wrapping basic CRUD calls like Order::create() inside OrderRepositoryInterface::store(), keep Eloquent directly inside your action classes.

Testing Trade-offs

Refactoring fat controllers this way dramatically cuts down test execution time. You no longer need to boot the full HTTP request stack just to test whether an order total calculation is accurate.

  • Unit Tests: Test CreateOrderAction directly by mocking the StripeClient interface. Response latency drops from 300ms down to around 15ms per test case.
  • HTTP Tests: Test OrderController using Storage::fake() or Queue::fake() to assert HTTP status codes and validation payload error returns without hitting third-party APIs.

By extracting Form Requests, single-task Actions, and queued Jobs, you maintain clear boundaries without clogging your codebase with dozens of meaningless abstractions.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Next.js Image Optimization: Sizes and Remote Patterns

    Next.js Image Optimization: Sizes and Remote Patterns

    Next.js Route Handlers vs Server Actions: The Real Boundary

    Next.js Route Handlers vs Server Actions: The Real Boundary

    Optimizing nextjs generatestaticparams at Scale

    Optimizing nextjs generatestaticparams at Scale

    Refactoring Laravel Fat Controllers Without Over-Engineering

    Refactoring Laravel Fat Controllers Without Over-Engineering

    Laravel Action Classes & DTOs: Clean Code vs Ceremony

    Laravel Action Classes & DTOs: Clean Code vs Ceremony

    Safe, resumable data backfills for Laravel

    Safe, resumable data backfills for Laravel

    Query Builder vs Eloquent: Real Cost in Laravel

    Query Builder vs Eloquent: Real Cost in Laravel

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense