Laravel MVC — Fat Controller Payment API Timeout
POST /api/payments timed out after 30 seconds due to a fat controller triggering 50+ N+1 queries.
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- MVC separates concerns: Model (data), View (presentation), Controller (orchestrator)
- Route maps URL to Controller method
- Controller delegates to Model, then returns a View with data
- Eloquent ORM is the default Model layer — rich relationships and query scopes
- Blade templates handle View logic—layouts, components, and inheritance
- Performance trap: N+1 queries in relationships; use eager loading (
with()) - Production insight: fat controllers are the #1 maintainability killer; move logic to models or services
MVC (Model-View-Controller) is a software architectural pattern that separates an application into three interconnected components. Laravel embraces this pattern as its default architecture, providing a clear structure: routes map URLs to controller methods, controllers delegate to models (often via Eloquent ORM) for data operations, and return rendered views (Blade templates) as HTTP responses.
This separation ensures that changes in one layer (e.g., database schema) do not ripple into the presentation layer without explicit handling.
In a typical Laravel application, a request travels: public/index.php → HTTP Kernel → Router → Middleware → Controller → Model → View → Response. Each component has a single responsibility. The controller is a thin orchestrator; the model encapsulates business logic and data access; the view handles only presentation logic.
Understanding this flow is critical because common production issues — slow pages, untestable code, inconsistent data — often stem from violating this separation.
Imagine a restaurant. The customer (browser) tells the waiter (Controller) what they want. The waiter goes to the kitchen (Model) to get the data, then brings back a beautifully plated dish (View) to the table. Nobody expects the customer to cook, and nobody expects the chef to serve — everyone has one job. That's MVC: three clear roles so your code never becomes a tangled mess where everything does everything.
Every Laravel app you've ever used — a blog, an e-commerce store, a SaaS dashboard — is built on a pattern called MVC. It's not a Laravel invention; it's been around since the 1970s. But Laravel takes that pattern and makes it feel so natural that most developers follow it without even realising they're applying a decades-old architectural principle. Understanding it deeply is what separates a developer who 'writes Laravel' from one who 'thinks in Laravel'.
Before MVC, web apps were a nightmare to maintain. PHP files mixed SQL queries, HTML markup, and business logic all in one place. Change the database table? You'd hunt through fifty files. Redesign the frontend? You'd break the data layer. MVC solves this by enforcing a strict separation of concerns — each layer has one job and only one job, which means changes in one layer rarely ripple into the others.
By the end of this article you'll be able to trace exactly how a browser request travels through a Laravel application from route to response, understand why fat controllers are an anti-pattern, know when logic belongs in a Model versus a Controller versus a Service class, and write code that a teammate can pick up and understand without a walkthrough.
What is Laravel MVC Pattern?
MVC (Model-View-Controller) is a software architectural pattern that separates an application into three interconnected components. Laravel embraces this pattern as its default architecture, providing a clear structure: routes map URLs to controller methods, controllers delegate to models (often via Eloquent ORM) for data operations, and return rendered views (Blade templates) as HTTP responses. This separation ensures that changes in one layer (e.g., database schema) do not ripple into the presentation layer without explicit handling.
In a typical Laravel application, a request travels: public/index.php → HTTP Kernel → Router → Middleware → Controller → Model → View → Response. Each component has a single responsibility. The controller is a thin orchestrator; the model encapsulates business logic and data access; the view handles only presentation logic.
Understanding this flow is critical because common production issues — slow pages, untestable code, inconsistent data — often stem from violating this separation.
<?php namespace Io\Thecodeforge\Http\Controllers; use Io\Thecodeforge\Models\Post; use Illuminate\Http\Request; class PostController extends Controller { public function index() { $posts = Post::with('author')->latest()->paginate(10); return view('posts.index', compact('posts')); } }
- Route is the menu — directs the order to the right chef
- Controller is the cook — reads the order, fetches ingredients (Model), and plates the meal (View)
- Model is the pantry — stores and manages the ingredients (data and logic)
- View is the plate — arranges the ingredients for the customer (browser)
with() on a relationship in a controller can silently degrade performance — add 100ms per request.The Request Lifecycle in Laravel MVC
Every request hits public/index.php, then the HTTP kernel. The router matches the URL to a route definition and dispatches it to the appropriate Controller method. That method interacts with Models (often via Eloquent) and returns a View with data. This flow is strict — the Controller never echoes HTML, the Model never handles HTTP input, and the View never writes to the database.
Here's a concrete example of a clean Controller that plays by the rules:
```php <?php
namespace Io\Thecodeforge\Http\Controllers;
use Io\Thecodeforge\Models\Order; use Illuminate\Http\Request;
class OrderController extends Controller { public function show(Order $order, Request $request) { // Implicit route model binding — Laravel fetches the Order by ID $items = $order->items()->with('product')->get(); return view('orders.show', compact('order', 'items')); } } ```
Notice the Controller is thin: it only orchestrates. The Model (Order) contains the relationship definition. The View (orders.show) renders the HTML. No SQL, no raw HTML in the Controller.
<?php namespace Io\Thecodeforge\Http\Controllers; use Io\Thecodeforge\Models\Order; use Illuminate\Http\Request; class OrderController extends Controller { public function show(Order $order, Request $request) { $items = $order->items()->with('product')->get(); return view('orders.show', compact('order', 'items')); } }
- Route is the menu — directs the order to the right chef
- Controller is the cook — reads the order, fetches ingredients (Model), and plates the meal (View)
- Model is the pantry — stores and manages the ingredients (data and logic)
- View is the plate — arranges the ingredients for the customer (browser)
php artisan route:cache) speeds up route matching by 5-10x but won't work with closures. Use controllers for all routes that need caching.with() on relationships to avoid N+1 — a single missing with can add 100ms per request in production.$_GET or $_POST directly — use $request->input() for testability.Controllers: The Orchestrators (Not the Brains)
A Controller's job is to accept an HTTP request, coordinate with Models or Services, and return a response. That's it. Fat controllers with 300 lines of business logic are the #1 maintainability problem in Laravel apps.
Use resource controllers for standard CRUD operations. They enforce a clear pattern: index, create, store, show, edit, update, destroy. Each method should be under 15 lines.
Dependency injection via the constructor or method injection ensures your controller is testable. Never new Service() inside a controller method — let Laravel's service container resolve it.
If you find yourself writing a controller method that does more than three things (validate input, call a service/model, return a view), break it down.
<?php namespace Io\Thecodeforge\Http\Controllers; use Io\Thecodeforge\Services\ProductService; use Illuminate\Http\Request; class ProductController extends Controller { public function __construct( private ProductService $productService ) {} public function store(Request $request) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'price' => 'required|numeric|min:0', ]); $product = $this->productService->create($validated); return redirect()->route('products.show', $product); } }
foreach loop or a complex arithmetic calculation inside a controller, ask yourself: does this really belong here? If not, move it to a model accessor, a service, or a presentation layer.ProductService and assert the controller calls the correct methods.App\Exceptions\Handler handles ModelNotFoundException gracefully.if chains for different user roles, that logic belongs in policies or form requests.Models: Where the Business Logic Lives
Models are not just database representations. They are the core of your domain logic. Eloquent models can hold relationships, scopes, accessors, mutators, and custom query methods. This is where you define how your data behaves.
For example, an Order model can have a total accessor that sums its items. A paid scope that filters unpaid orders. A sendConfirmation method that triggers a notification. All of this belongs in the model, not the controller.
Use Eloquent's relationship methods (hasMany, belongsTo, morphMany) to define how models connect. Then leverage eager loading () to avoid the N+1 problem.with()
Validation rules that are tied to the model's state belong in store or update methods in a Form Request, but computed properties belong in the model.
<?php namespace Io\Thecodeforge\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class Order extends Model { protected $fillable = ['customer_id', 'status']; public function items(): HasMany { return $this->hasMany(OrderItem::class); } public function scopePaid($query) { return $query->where('status', 'paid'); } public function getTotalAttribute(): float { return $this->items->sum(fn ($item) => $item->price * $item->quantity); } public function sendConfirmation(): void { // Logic to send email — could delegate to a Notification class } }
getTotalAttribute are cached per model instance — they don't re-query unless you reload the relationship. Use with('items') before accessing $order->total.Order::where('status', 'paid')->get(), write Order::paid()->get().set{Name}Attribute) are great for normalising data, e.g., hashing passwords or trimming whitespace.Views: Blade Templates, Layouts, and Components
Blade is Laravel's powerful templating engine. It extends PHP with control structures, layouts, components, and partials. Views should only contain presentation logic — loops, if/else, formatting — never raw SQL queries or complex business math.
Use layouts for common page structure. Define sections with @yield or use Blade components for reusable UI pieces. Components can accept data and have their own logic encapsulated in a component class.
Always pass data from the controller to the view using or compact(). Avoid using the with()View::share() globally except for shared navigation data.
Avoid writing complex PHP in Blade. If you need a helper, create a Blade directive or a dedicated view composer.
@extends('layouts.app') @section('content') <h1>Order #{{ $order->id }}</h1> <ul> @foreach($items as $item) <li>{{ $item->product->name }} x {{ $item->quantity }} — ${{ number_format($item->price, 2) }}</li> @endforeach </ul> <p><strong>Total:</strong> ${{ number_format($order->total, 2) }}</p> @endsection
AppServiceProvider instead of passing it in every controller.storage/framework/views. Run php artisan view:clear after moving or renaming files.@json to pass PHP data to JavaScript inline — it auto-escapes.@php blocks inside Blade for business logic — that's a sign the logic belongs in the controller or model.layouts/app.blade.php)components/)Advanced MVC: Service Layer and Repositories
When controllers get too heavy or models start to hold business logic that spans multiple models, introduce a service layer. Services are plain PHP classes (often without extending Eloquent) that encapsulate complex operations.
For example, an OrderService can handle placeOrder() which creates the order, deducts inventory, charges the customer, and sends confirmation. The controller calls $orderService->placeOrder($request).
Repositories are an additional abstraction if you need to switch between databases or mock the data layer in tests. In most Laravel apps, Eloquent is sufficient, so repositories can add unnecessary complexity. Use them only when you have a clear reason (multiple data sources, heavy caching logic).
<?php namespace Io\Thecodeforge\Services; use Io\Thecodeforge\Models\Order; use Io\Thecodeforge\Models\Product; use Illuminate\Support\Facades\DB; class OrderService { public function placeOrder(array $data, int $customerId): Order { return DB::transaction(function () use ($data, $customerId) { $order = Order::create([ 'customer_id' => $customerId, 'status' => 'pending', ]); foreach ($data['items'] as $item) { $product = Product::findOrFail($item['product_id']); $product->decrement('stock', $item['quantity']); $order->items()->create([ 'product_id' => $product->id, 'quantity' => $item['quantity'], 'price' => $product->price, ]); } // Charge the customer (third-party API call) // $this->paymentService->charge($order, $data['payment_token']); $order->update(['status' => 'paid']); return $order; }); } }
- Controller reads the order and hands it to the chef
- Service chef orchestrates many ingredients (models, APIs)
- Model pantry stores the raw ingredients
- View plate presents the final dish
DB::transaction) are critical for atomic operations — without them, a payment could succeed while inventory fails to update.Why MVC? The Real Reason We Separate Concerns
You don't use MVC because the manual says so. You use it because without it, your routes/web.php becomes a 2,000-line graveyard of Closure-based logic that nobody touches without breaking three other features. MVC forces a contract: routes dispatch, controllers orchestrate, models own business rules, views present. That contract makes your code predictable when a hotfix lands at 2 AM.
Consider what happens without MVC: raw SQL in Blade templates, form validation in routes, queries scattered across controllers. Two months later, you can't tell if User::where('active', 1) belongs to billing or notifications. MVC prevents that chaos by enforcing a single source of truth for each concern.
The payoff? Onboarding new devs takes days, not weeks. Refactoring legacy features becomes surgical, not exploratory. And when production breaks, you grep a specific directory instead of the entire codebase.
// io.thecodeforge // Without MVC — logic leaks everywhere Route::get('/dashboard', function () { $users = DB::table('users')->where('active', 1)->get(); $html = '<ul>'; foreach ($users as $user) { $html .= '<li>' . $user->name . '</li>'; } return $html . '</ul>'; }); // With MVC — predictable, testable class DashboardController { public function __invoke(): View { $activeUsers = User::query()->whereActive(true)->get(); return view('dashboard', compact('activeUsers')); } }
Advantages of Using MVC: What Breaks if You Skip It
MVC isn't abstract theory. It's a survival mechanism for applications that live longer than one sprint. Three concrete advantages: testability, parallel development, and caching granularity.
Testability: Controllers handle HTTP concerns; models handle logic. When you phpunit a model, you're not mocking request objects or session data. That means your test suite runs in seconds, not minutes. Without MVC, every test is a fragile integration test that breaks when someone renames a Blade partial.
Parallel development: With MVC, a frontend dev can craft Blade components while a backend dev optimizes a repository query. The interface is the method signature, not debugging in a view. You don't wait for the database to be seeded to see UI changes — you hardcode a ViewModel.dump()
Caching granularity: Because views are isolated, you can cache rendered fragments. Because models are isolated, you can cache query results. Without separation, you cache the entire response or nothing. That's binary thinking, and it kills performance at scale.
// io.thecodeforge // Model method — cache-safe, reusable class User extends Authenticatable { public function activeCount(): int { return Cache::remember('users.active.count', 3600, function () { return $this->whereActive(true)->count(); }); } } // Controller — only concerns itself with HTTP class UserStatsController { public function index(User $user): View { return view('stats', [ 'activeCount' => $user->activeCount(), ]); } }
Features of Laravel That Make MVC Actually Work in Production
Laravel's features aren't marketing fluff. They're the reason MVC patterns survive real-world pressure. Four features matter most:
Eloquent ORM: The model layer in MVC lives or dies by its data layer. Eloquent gives you eager loading, global scopes, and accessors — all without raw SQL. When a junior writes $order->items->sum('price'), they're using MVC correctly without knowing it. The model encapsulates the relationship, not the controller.
Blade Components: Views aren't just HTML dumps. Blade components give you reusable UI with scoped logic. Think @alert('warning') instead of manually constructing a danger div in every view. Components enforce the view's single responsibility: presentation, not computation.
Artisan Commands: MVC requires discipline. Artisan generates boilerplate — php artisan make:model -mf creates model, migration, and factory in one command. That speed removes the friction argument people use to skip MVC.
Form Requests: Validation leaking into controllers ruins MVC. Form Requests keep validation in the HTTP layer where it belongs. Your controller stays clean; your validation logic is reusable between endpoints.
// io.thecodeforge // Form Request — validation stays in HTTP layer class StoreUserRequest extends FormRequest { public function rules(): array { return [ 'email' => ['required', 'email', 'unique:users,email'], 'role' => ['required', 'in:admin,editor'], ]; } } // Controller — no validation clutter class UserController { public function store(StoreUserRequest $request) { User::create($request->validated()); return redirect()->route('users.index'); } }
The Fat Controller That Brought Down a Payment API
DiscountService, used eager loading (with('items','discounts')), and refactored the Controller to three lines: validate, call service, return response. Timeout vanished.- Keep Controllers thin — they should only parse input, call a service or model, and return a response.
- Never do N+1 loops inside a Controller. Use Eloquent relationships with eager loading.
- If a Controller method exceeds 20 lines, extract the logic into a dedicated class (Service, Action, or Repository).
view('name', compact('var')) or view('name')->with('var', $value). Use dd($var) in the Controller before returning the view to verify the variable exists.php artisan route:list to confirm the route is registered. Check that the route URI matches exactly (including trailing slashes). Verify the Controller method name is correct and the namespace is properly set in RouteServiceProvider.DB::enableQueryLog(). Identify loops that call relationships without eager loading. Add ->with('relationship') to the initial query. Use $model->load('relationship') if already retrieved.tail -n 100 storage/logs/laravel.logphp artisan config:clear && php artisan route:clearphp artisan view:clearCheck file permissions: `ls -la resources/views/folder/name.blade.php`composer dump-autoload if the error persists after clearing views.DB::enableQueryLog(); $model->relation; dd(DB::getQueryLog());Check foreign key column names: the default `model_id` must exist and be populated.->with('relation') to ensure eager loading, or verify the relationship definition in the Model.| Component | Responsibility | Example in Laravel | Common Pitfall |
|---|---|---|---|
| Route | Map URL to Controller method | Route::get('/orders', [OrderController::class, 'index']) | Route closures can't be cached for performance |
| Controller | Accept request, delegate, return response | OrderController with , , | Fat controllers (>20 lines per method) |
| Model | Business logic, relationships, data access | Order extends Model with scopes and accessors | N+1 queries when relationships are not eager loaded |
| View | Presentation, HTML rendering | orders/index.blade.php using Blade directives | Complex PHP logic inside @php blocks |
| Service | Orchestrate complex workflows | PaymentService::charge($order) | Over-engineering when not needed |
| File | Command / Code | Purpose |
|---|---|---|
| app | namespace Io\Thecodeforge\Http\Controllers; | What is Laravel MVC Pattern? |
| app | namespace Io\Thecodeforge\Http\Controllers; | The Request Lifecycle in Laravel MVC |
| app | namespace Io\Thecodeforge\Http\Controllers; | Controllers |
| app | namespace Io\Thecodeforge\Models; | Models |
| resources | @extends('layouts.app') | Views |
| app | namespace Io\Thecodeforge\Services; | Advanced MVC |
| BadVsMvcController.php | Route::get('/dashboard', function () { | Why MVC? The Real Reason We Separate Concerns |
| CacheExample.php | class User extends Authenticatable | Advantages of Using MVC |
| FeatureMvcExample.php | class StoreUserRequest extends FormRequest | Features of Laravel That Make MVC Actually Work in Productio |
Key takeaways
with() to eliminate the N+1 problem.findOrFail.Common mistakes to avoid
4 patternsPutting business logic in controllers
Forgetting to eager load relationships
with('relationship') on the initial query. If already retrieved, call $model->load('relationship').Using `@php` or `<?php` in Blade for business logic
Not using route model binding
Order::findOrFail($id) calls in controllers; routes are verbose.Interview Questions on This Topic
Describe the full request lifecycle in Laravel MVC, from URL to response.
What is the N+1 problem in Eloquent and how do you solve it?
$orders = Order::all(); foreach($orders as $order) { echo $order->customer->name; } executes 1 query for orders and then 1 query per order for the customer (N queries). Fix: use Order::with('customer')->get(); which loads all customers in a single second query. Laravel automatically resolves the parent keys.Explain the difference between using a Service class and putting logic in the Model. When would you choose one over the other?
Why are 'fat controllers' considered an anti-pattern in Laravel?
How does Laravel's service container support dependency injection in controllers?
OrderController constructor expects OrderService $service, the container instantiates OrderService automatically (and its own dependencies recursively). This makes controllers testable—you can mock OrderService and bind it in the container during unit tests.Frequently Asked Questions
Laravel MVC Pattern is a fundamental concept in PHP. Think of it as a tool — once you understand its purpose, you'll reach for it constantly.
Yes. Laravel supports other ORMs like Doctrine or raw SQL. You can build custom Model classes that use DB:: queries. However, Eloquent is tightly integrated and recommended for most apps.
Use php artisan make:controller --resource when you have a database-backed resource that needs the full set of CRUD operations (index, create, store, show, edit, update, destroy). It auto-generates the methods and the route registration.
Both pass data to the view. compact('var') creates an associative array from variable names. with('key', $value) explicitly names the key. Use whichever is more readable — compact is concise when the variable name matches the view key.
Use PHPUnit with Laravel's testing helpers. For controllers, use and get() methods to simulate HTTP requests and assert responses. For models, test relationships, scopes, and accessors directly. Mock services when testing controllers that depend on external services.post()
20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.
That's Laravel. Mark it forged?
5 min read · try the examples if you haven't