Fat controllers, business logic in models, service classes nobody understands — the Laravel codebase antipatterns that work fine at 1,000 users and collapse at 100,000. Here’s the clean architecture approach that actually survives contact with real traffic.
Most Laravel applications start clean. A few routes. A few controllers. Eloquent queries where they need to be. The code is readable, the team is happy, and everything ships fast.
Then six months pass. The controllers grow. Someone puts business logic in a model because it seemed like the right place. Someone else creates a UserService that ends up with 40 methods, none of which are cohesive. A third person adds validation to three different layers because they weren’t sure where it belonged. The codebase still works, but nobody feels good about opening it anymore.
This isn’t a story about incompetent developers. It’s a story about architecture decisions that weren’t made early enough — and the compounding cost of deferring them.
This post describes the architecture I’ve settled on after years of building and maintaining Laravel applications at scale: one that’s pragmatic, that Laravel developers recognise immediately, and that remains understandable and modifiable when the application is three years old and ten times larger.
The Antipatterns That Got Us Here
The Fat Controller
// ✗ A controller that does everything
class OrderController extends Controller
{
public function store(Request $request): JsonResponse
{
// Validation
$request->validate([
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'exists:products,id'],
'items.*.qty' => ['required', 'integer', 'min:1'],
'shipping_method' => ['required', 'in:standard,express,overnight'],
'coupon_code' => ['nullable', 'string'],
]);
// Business logic — coupon validation
$discount = 0;
if ($request->coupon_code) {
$coupon = Coupon::where('code', $request->coupon_code)
->where('active', true)
->where('expires_at', '>', now())
->first();
if (!$coupon) {
return response()->json(['error' => 'Invalid coupon'], 422);
}
$discount = $coupon->discount_percent / 100;
}
// More business logic — inventory check
foreach ($request->items as $item) {
$product = Product::find($item['id']);
if ($product->stock < $item['qty']) {
return response()->json([
'error' => "Insufficient stock for {$product->name}"
], 422);
}
}
// More business logic — order creation
$order = DB::transaction(function () use ($request, $discount) {
$subtotal = collect($request->items)->sum(fn($i) =>
Product::find($i['id'])->price * $i['qty']
);
$order = Order::create([
'user_id' => auth()->id(),
'subtotal' => $subtotal,
'discount' => $subtotal * $discount,
'total' => $subtotal * (1 - $discount),
'status' => 'pending',
]);
foreach ($request->items as $item) {
$order->items()->create([
'product_id' => $item['id'],
'quantity' => $item['qty'],
'price' => Product::find($item['id'])->price,
]);
Product::find($item['id'])->decrement('stock', $item['qty']);
}
return $order;
});
// Side effects
Mail::to(auth()->user())->send(new OrderConfirmationMail($order));
event(new OrderPlaced($order));
return response()->json(new OrderResource($order), 201);
}
}
This controller has six distinct responsibilities. It validates, applies business rules, queries the database three times per loop iteration (N+1), manages a transaction, sends email, fires events, and formats the response. It is impossible to unit test. Changing any one behaviour requires reading and understanding all the others.
The God Service
The “fix” many teams apply to fat controllers is extracting a service class. The problem is that service classes without boundaries grow into god objects:
// ✗ A service with no coherent identity
class UserService
{
public function register(array $data): User { }
public function login(array $credentials): string { }
public function logout(User $user): void { }
public function updateProfile(User $user, array $data): User { }
public function changePassword(User $user, string $password): void { }
public function forgotPassword(string $email): void { }
public function resetPassword(string $token, string $password): void { }
public function verifyEmail(string $token): void { }
public function uploadAvatar(User $user, UploadedFile $file): string { }
public function generateApiToken(User $user): string { }
public function revokeApiTokens(User $user): void { }
public function findByEmail(string $email): ?User { }
public function deactivate(User $user): void { }
public function exportData(User $user): array { }
public function calculateLifetimeValue(User $user): float { }
// ... 15 more methods
}
This class has no single responsibility. It’s a namespace that happens to share the word “User.” Testing any one method requires instantiating or mocking all the dependencies of all the others.
The Modern Architecture: Four Layers, Clear Responsibilities
The architecture I recommend for Laravel applications in 2026 is built on four clear layers:
HTTP Layer Controllers, Form Requests, API Resources
→ Handles HTTP concerns only. No business logic.
Action Layer Single-purpose classes that do one thing
→ Encapsulates one business operation. Testable in isolation.
Domain Layer Models, Events, Value Objects, Interfaces
→ Represents your business domain.
Infrastructure Repositories, External Service Wrappers, Jobs
→ Handles persistence, external APIs, async work.
The traffic flows in one direction:
Request → FormRequest (validate) → Controller → Action → Domain/Infrastructure → Resource → Response
The controller becomes a thin orchestrator. The action becomes the unit of testable business logic.
The Action Class Pattern
An action class does exactly one thing. It has one public method (execute or handle), receives all its dependencies via constructor injection, and returns a result.
// app/Actions/PlaceOrder.php
<?php
namespace App\Actions;
use App\Events\OrderPlaced;
use App\Exceptions\InsufficientStockException;
use App\Exceptions\InvalidCouponException;
use App\Http\Requests\PlaceOrderRequest;
use App\Models\Coupon;
use App\Models\Order;
use App\Models\Product;
use App\Notifications\OrderConfirmation;
use Illuminate\Support\Facades\DB;
class PlaceOrder
{
public function execute(PlaceOrderRequest $request): Order
{
$coupon = $this->resolveCoupon($request->coupon_code);
$products = $this->resolveProducts($request->items);
$this->checkInventory($products, $request->items);
$order = DB::transaction(function () use ($request, $products, $coupon) {
return $this->createOrder($request, $products, $coupon);
});
$request->user()->notify(new OrderConfirmation($order));
OrderPlaced::dispatch($order);
return $order;
}
private function resolveCoupon(?string $code): ?Coupon
{
if (!$code) return null;
$coupon = Coupon::valid()->where('code', $code)->first();
if (!$coupon) {
throw new InvalidCouponException($code);
}
return $coupon;
}
private function resolveProducts(array $items): array
{
$ids = collect($items)->pluck('id');
return Product::whereIn('id', $ids)
->get()
->keyBy('id')
->all();
}
private function checkInventory(array $products, array $items): void
{
foreach ($items as $item) {
$product = $products[$item['id']];
if ($product->stock < $item['qty']) {
throw new InsufficientStockException($product, $item['qty']);
}
}
}
private function createOrder(PlaceOrderRequest $request, array $products, ?Coupon $coupon): Order
{
$subtotal = collect($request->items)
->sum(fn($item) => $products[$item['id']]->price * $item['qty']);
$discount = $coupon ? $subtotal * ($coupon->discount_percent / 100) : 0;
$order = Order::create([
'user_id' => $request->user()->id,
'subtotal' => $subtotal,
'discount' => $discount,
'total' => $subtotal - $discount,
'status' => 'pending',
]);
foreach ($request->items as $item) {
$order->items()->create([
'product_id' => $item['id'],
'quantity' => $item['qty'],
'price' => $products[$item['id']]->price,
]);
$products[$item['id']]->decrement('stock', $item['qty']);
}
if ($coupon) {
$coupon->increment('times_used');
}
return $order;
}
}
The Thin Controller
// app/Http/Controllers/Api/OrderController.php
<?php
namespace App\Http\Controllers\Api;
use App\Actions\PlaceOrder;
use App\Http\Controllers\Controller;
use App\Http\Requests\PlaceOrderRequest;
use App\Http\Resources\OrderResource;
class OrderController extends Controller
{
public function __construct(
private readonly PlaceOrder $placeOrder
) {}
public function store(PlaceOrderRequest $request): OrderResource
{
$order = $this->placeOrder->execute($request);
return new OrderResource($order);
}
}
The controller is five lines of meaningful code. It does exactly one thing: bridge the HTTP request to the action and the action result to an HTTP response. Changing validation doesn’t touch the controller. Changing business logic doesn’t touch the controller. Changing the response format doesn’t touch the action.
Form Request Classes: Validation Lives Here, Always
Validation is an HTTP concern. It validates that the incoming HTTP request has the shape and values the application needs. It does not belong in controllers, models, or actions.
// app/Http/Requests/PlaceOrderRequest.php
<?php
namespace App\Http\Requests;
use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PlaceOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.id' => ['required', 'integer', Rule::exists('products', 'id')->where('active', true)],
'items.*.qty' => ['required', 'integer', 'min:1', 'max:100'],
'shipping_method' => ['required', Rule::in(['standard', 'express', 'overnight'])],
'coupon_code' => ['nullable', 'string', 'max:50'],
'shipping_address_id' => ['required', Rule::exists('addresses', 'id')->where('user_id', $this->user()->id)],
];
}
public function messages(): array
{
return [
'items.required' => 'Your cart is empty.',
'items.min' => 'Please add at least one item to your cart.',
'items.*.id.exists' => 'One or more products are no longer available.',
'items.*.qty.max' => 'Maximum quantity per item is 100.',
];
}
// Typed accessors — controllers and actions use these instead of raw request data
public function shippingMethod(): string
{
return $this->validated('shipping_method');
}
public function couponCode(): ?string
{
return $this->validated('coupon_code');
}
public function items(): array
{
return $this->validated('items');
}
}
Typed Accessors in Form Requests
Adding typed methods to Form Requests eliminates stringly-typed $request->input('key') calls throughout your codebase and makes the contract between the request and the action explicit:
// Without typed accessors — stringly-typed, refactoring-unfriendly
$coupon = $request->input('coupon_code'); // string or null — type unknown to IDE
// With typed accessors — fully typed, IDE-friendly
$coupon = $request->couponCode(); // ?string — explicit, refactorable
API Resources: Response Formatting Lives Here, Always
API Resources control what data leaves your application. They are the serialisation layer. Business logic, data transformation, and formatting all happen here — not in controllers, not in Eloquent models.
// app/Http/Resources/OrderResource.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'status' => $this->status,
'status_label' => $this->status_label, // from model accessor
'pricing' => [
'subtotal' => $this->subtotal,
'discount' => $this->discount,
'total' => $this->total,
'currency' => 'INR',
],
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'user' => new UserResource($this->whenLoaded('user')),
'shipping_address'=> new AddressResource($this->whenLoaded('shippingAddress')),
'timestamps' => [
'placed_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'shipped_at' => $this->shipped_at?->toISOString(),
],
// Conditional fields — only included when relevant
'tracking_url' => $this->when($this->tracking_number, fn() =>
"https://track.courier.com/{$this->tracking_number}"
),
'can_cancel' => $this->when(
$request->user()?->id === $this->user_id,
fn() => $this->status === 'pending'
),
];
}
}
Domain Exceptions: Replace Magic Strings with Typed Errors
Returning response()->json(['error' => 'invalid_coupon'], 422) from inside business logic means controllers and actions are deciding on HTTP status codes — an HTTP concern leaking into the domain. Typed exceptions cleanly separate the concern:
// app/Exceptions/InvalidCouponException.php
<?php
namespace App\Exceptions;
use Exception;
class InvalidCouponException extends Exception
{
public function __construct(public readonly string $couponCode)
{
parent::__construct("The coupon '{$couponCode}' is invalid or has expired.");
}
}
// app/Exceptions/InsufficientStockException.php
class InsufficientStockException extends Exception
{
public function __construct(
public readonly \App\Models\Product $product,
public readonly int $requestedQty,
) {
parent::__construct(
"Insufficient stock for '{$product->name}'. Available: {$product->stock}, requested: {$requestedQty}."
);
}
}
// app/Exceptions/Handler.php — map domain exceptions to HTTP responses
use App\Exceptions\InvalidCouponException;
use App\Exceptions\InsufficientStockException;
public function register(): void
{
$this->renderable(function (InvalidCouponException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => $e->getMessage(),
'code' => 'INVALID_COUPON',
], 422);
}
});
$this->renderable(function (InsufficientStockException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => $e->getMessage(),
'code' => 'INSUFFICIENT_STOCK',
'product_id' => $e->product->id,
'available_qty' => $e->product->stock,
], 422);
}
});
}
The action throws domain exceptions. The HTTP layer — the global exception handler — decides how to serialise them into HTTP responses. The action never knows about HTTP status codes.
Value Objects: Make Invalid States Unrepresentable
Value objects encapsulate domain concepts and validate their own invariants. They make it impossible to create a value that violates business rules.
// app/ValueObjects/Money.php
<?php
namespace App\ValueObjects;
use InvalidArgumentException;
final class Money
{
public function __construct(
public readonly int $amount, // stored in paise (smallest unit)
public readonly string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException("Money amount cannot be negative: {$amount}");
}
if (strlen($currency) !== 3) {
throw new InvalidArgumentException("Currency must be a 3-letter ISO code: {$currency}");
}
}
public static function fromRupees(float $rupees, string $currency = 'INR'): self
{
return new self((int) round($rupees * 100), $currency);
}
public function add(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(Money $other): self
{
$this->assertSameCurrency($other);
$result = $this->amount - $other->amount;
if ($result < 0) {
throw new InvalidArgumentException('Cannot subtract: result would be negative');
}
return new self($result, $this->currency);
}
public function multiply(float $factor): self
{
return new self((int) round($this->amount * $factor), $this->currency);
}
public function toRupees(): float
{
return $this->amount / 100;
}
public function format(): string
{
return '₹' . number_format($this->toRupees(), 2);
}
private function assertSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException(
"Cannot operate on different currencies: {$this->currency} and {$other->currency}"
);
}
}
}
CQRS Lite: Separating Reads from Writes
Full CQRS (Command Query Responsibility Segregation) is complex. But the pattern’s core insight — that reading data and changing data are fundamentally different operations — is worth applying even at a simpler level.
In practice, this means:
- Commands (writes) go through Action classes, which validate, run business logic, and produce side effects
- Queries (reads) go through dedicated Query classes that are optimised purely for reading
// app/Queries/GetOrderSummary.php
<?php
namespace App\Queries;
use App\Http\Resources\OrderSummaryResource;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
class GetOrderSummary
{
public function execute(int $userId, array $filters = []): AnonymousResourceCollection
{
$query = DB::table('orders')
->join('users', 'orders.user_id', '=', 'users.id')
->select([
'orders.id',
'orders.status',
'orders.total',
'orders.created_at',
DB::raw('COUNT(order_items.id) as items_count'),
])
->leftJoin('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.user_id', $userId)
->groupBy('orders.id', 'orders.status', 'orders.total', 'orders.created_at');
if (!empty($filters['status'])) {
$query->where('orders.status', $filters['status']);
}
if (!empty($filters['from'])) {
$query->where('orders.created_at', '>=', $filters['from']);
}
$orders = $query->latest('orders.created_at')->paginate(20);
return OrderSummaryResource::collection($orders);
}
}
Query classes can use raw query builder, Eloquent, or even a read replica — the calling code doesn’t care. They can be tested independently of any write path. They can be cached, indexed, or denormalised without touching the write path.
The Directory Structure That Reflects the Architecture
app/
├── Actions/
│ ├── Orders/
│ │ ├── PlaceOrder.php
│ │ ├── CancelOrder.php
│ │ └── RefundOrder.php
│ ├── Users/
│ │ ├── RegisterUser.php
│ │ ├── UpdateUserProfile.php
│ │ └── DeactivateUser.php
│ └── Payments/
│ ├── ChargeCard.php
│ └── ProcessRefund.php
│
├── Queries/
│ ├── GetOrderSummary.php
│ ├── GetUserDashboard.php
│ └── GetProductCatalogue.php
│
├── Exceptions/
│ ├── InvalidCouponException.php
│ ├── InsufficientStockException.php
│ └── PaymentFailedException.php
│
├── ValueObjects/
│ ├── Money.php
│ ├── EmailAddress.php
│ └── PhoneNumber.php
│
├── Http/
│ ├── Controllers/Api/
│ │ ├── OrderController.php ← thin, delegates to Action/Query
│ │ └── UserController.php
│ ├── Requests/
│ │ ├── PlaceOrderRequest.php
│ │ └── UpdateProfileRequest.php
│ └── Resources/
│ ├── OrderResource.php
│ └── UserResource.php
│
└── Models/
├── Order.php ← Eloquent relationships, scopes, accessors
├── User.php ← no business logic
└── Product.php
Testing the Architecture
One of the primary benefits of this structure is testability. Each layer can be tested in isolation:
Testing Actions
// tests/Unit/Actions/PlaceOrderTest.php
class PlaceOrderTest extends TestCase
{
use RefreshDatabase;
public function test_places_order_successfully(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 1000, 'stock' => 10]);
$request = PlaceOrderRequest::create('/orders', 'POST', [
'items' => [['id' => $product->id, 'qty' => 2]],
'shipping_method' => 'standard',
'shipping_address_id' => Address::factory()->for($user)->create()->id,
]);
$request->setUserResolver(fn() => $user);
$action = new PlaceOrder();
$order = $action->execute($request);
$this->assertEquals(2000, $order->subtotal);;
$this->assertEquals(0, $order->discount);
$this->assertEquals('pending', $order->status);
$this->assertDatabaseHas('orders', ['id' => $order->id, 'user_id' => $user->id]);
$this->assertDatabaseHas('order_items', ['order_id' => $order->id, 'product_id' => $product->id]);
$this->assertDatabaseHas('products', ['id' => $product->id, 'stock' => 8]);
}
public function test_throws_on_invalid_coupon(): void
{
$this->expectException(InvalidCouponException::class);
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 10]);
$request = PlaceOrderRequest::create('/orders', 'POST', [
'items' => [['id' => $product->id, 'qty' => 1]],
'shipping_method' => 'standard',
'shipping_address_id' => Address::factory()->for($user)->create()->id,
'coupon_code' => 'INVALID_CODE',
]);
$request->setUserResolver(fn() => $user);
(new PlaceOrder())->execute($request);
}
public function test_throws_on_insufficient_stock(): void
{
$this->expectException(InsufficientStockException::class);
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 1]);
$request = PlaceOrderRequest::create('/orders', 'POST', [
'items' => [['id' => $product->id, 'qty' => 5]],
'shipping_method' => 'standard',
'shipping_address_id' => Address::factory()->for($user)->create()->id,
]);
$request->setUserResolver(fn() => $user);
(new PlaceOrder())->execute($request);
}
}
Testing Controllers (Feature Tests)
// tests/Feature/Api/OrderControllerTest.php
class OrderControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_place_order(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 500, 'stock' => 20]);
$address = Address::factory()->for($user)->create();
$this->actingAs($user)
->postJson('/api/orders', [
'items' => [['id' => $product->id, 'qty' => 3]],
'shipping_method' => 'standard',
'shipping_address_id' => $address->id,
])
->assertCreated()
->assertJsonPath('data.status', 'pending')
->assertJsonPath('data.pricing.total', 1500);
}
public function test_unauthenticated_request_returns_401(): void
{
$this->postJson('/api/orders', [])->assertUnauthorized();
}
}
Migration Strategy: How to Refactor Without Stopping the World
If you’re looking at an existing fat-controller codebase and wondering where to start — here’s the incremental approach:
Phase 1 (Week 1–2): Stop the bleeding
- Add Form Requests to every controller that doesn’t have them
- Move validation out of controllers entirely
- No new business logic goes into controllers or models
Phase 2 (Week 3–4): Extract the most-tested paths
- Identify the 3–5 most critical user journeys
- Extract them into Action classes first
- Write unit tests for each action
- Controllers call actions; nothing else changes
Phase 3 (Month 2): Extract reads
- Identify the slowest, most complex queries
- Extract them into Query classes
- These are the easiest wins for caching and read replicas
Phase 4 (Ongoing): Domain exceptions and value objects
- Replace stringly-typed error handling with domain exceptions
- Introduce value objects for domain concepts (Money, Email, Phone)
- Register exception renderers in the global handler
The Principles Behind the Architecture
Every architectural decision in this post follows from three principles:
1. Each class has one reason to change. A controller changes when the HTTP contract changes. An action changes when the business rule changes. A resource changes when the API response format changes. Changes are localised.
2. Business logic is testable without HTTP. The action knows nothing about HTTP. Tests don’t need $this->postJson() to test business rules. They instantiate the action and call execute().
3. The architecture communicates intent. Looking at the app/Actions directory tells you every business operation the application performs. Looking at app/Queries tells you every read path. The directory structure is documentation.
Final Thoughts
The architecture described here is not revolutionary. It draws from CQRS, Clean Architecture, and Domain-Driven Design — but applies only the parts that are practical in a Laravel context without requiring a framework rewrite or a complete application rebuild.
The single most impactful change you can make today: stop writing business logic in controllers. Extract it to an Action class. Give it a focused name (PlaceOrder, CancelSubscription, VerifyPhoneNumber). Write one test for it. That test will catch every future regression on that operation, permanently.
The codebase that survives at 100,000 users isn’t cleverer than the one that breaks — it’s more organised. Each piece of logic has a home. That home is predictable. Developers who have never seen the code before can find what they’re looking for in minutes, not hours.
That’s the architecture worth building.
