Laravel MVC Architecture Explained
If you have just understood Laravel's folder structure, MVC is the next concept that determines whether your code stays clean or turns into a mess six months from now.
Most beginners know the definitions — Model handles data, View handles UI, Controller handles logic. But when you start building real features, things start mixing together. The controller grows to 300 lines. Database queries appear inside Blade files. Business logic ends up in routes. The app still works, but it becomes progressively harder to change anything without breaking something else.
This lesson explains not just what MVC is, but how it actually flows in a real Laravel request — and more importantly, what goes wrong when you ignore it.
What MVC Stands For
MVC is a design pattern that separates an application into three distinct layers, each with one clearly defined responsibility:
ComponentResponsibilityIn LaravelModelData and database interactionapp/Models/ViewPresentation and UIresources/views/ControllerRequest flow — connects Model and Viewapp/Http/Controllers/
The core rule is simple: each layer does one job and stays out of the other layers' work. Problems begin the moment one layer starts doing another's job.
The Full Request Flow — Step by Step
Let's trace a complete request from when a user types a URL to when they see a page — using a real example where a user visits /posts.
Step 1 — The Route Receives the Request
// routes/web.php
Route::get('/posts', [PostController::class, 'index']);
The route's only job is to map a URL to a controller method. It does not fetch data. It does not contain logic. It simply says: "When someone visits /posts, hand this request to PostController@index."
Step 2 — The Controller Handles the Request
// app/Http/Controllers/PostController.php
public function index()
{
$posts = Post::published()->latest()->take(10)->get();
return view('posts.index', compact('posts'));
}
The controller does three things and nothing more:
- Receives the request
- Asks the Model for data
- Passes that data to the View and returns the response
Notice the controller is not writing the query logic — it is calling Post::published(), a scope defined on the model. The controller does not need to know how "published" is defined. It just asks for published posts.
Step 3 — The Model Interacts with the Database
// app/Models/Post.php
class Post extends Model
{
protected $fillable = ['title', 'content', 'user_id', 'is_published'];
// Relationship — a post belongs to a user
public function user()
{
return $this->belongsTo(User::class);
}
// Relationship — a post has many comments
public function comments()
{
return $this->hasMany(Comment::class);
}
// Query scope — reusable filter for published posts
public function scopePublished($query)
{
return $query->where('is_published', true);
}
}
The model owns everything about the data:
- Which fields can be saved (
$fillable) - How this data relates to other data (relationships)
- Reusable query logic (scopes like
scopePublished)
A scope defined on the model can be reused in any controller across the entire application. If the definition of "published" ever changes, you update it in one place — the model.
Step 4 — The View Renders the Output
{{-- resources/views/posts/index.blade.php --}}
@extends('layouts.app')
@section('content')
Latest Posts
@forelse ($posts as $post)
{{ $post->title }}
By {{ $post->user->name }} · {{ $post->created_at->diffForHumans() }}
Read more
@empty
No posts available yet.
@endforelse
@endsection
The view receives the $posts variable already prepared by the controller and simply displays it. No queries. No business logic. No decisions. Just presentation.
The Complete Flow Visualized
Browser Request
↓
Route (routes/web.php)
↓
Controller (receives request, coordinates)
↓
Model (fetches/saves data from database)
↓
Controller (receives data back from model)
↓
View (renders HTML with the data)
↓
Browser Response
The critical point most beginners miss: the Model never talks directly to the View. Data always flows through the Controller. The Controller is the coordinator — it collects data from the Model and hands it to the View.
Common Mistakes and What They Actually Break
Understanding MVC in theory is easy. Sticking to it under pressure — when you just need something to work quickly — is where most developers slip. Here are the three most common violations and why they cause real problems.
Mistake 1 — Database Queries Inside Blade Views
{{-- BAD: query inside a Blade file --}}
@if ($user->posts()->count() > 5)
Prolific Author
@endif
This looks harmless. It works. But if this snippet appears ten times across different pages, you have ten database queries firing during rendering — queries that are invisible, hard to find during debugging, and impossible to cache easily. Views should receive pre-processed data, not fetch their own.
The fix — prepare in the controller:
// Controller
$isProlificAuthor = $user->posts()->count() > 5;
return view('profile', compact('user', 'isProlificAuthor'));
{{-- View --}}
@if ($isProlificAuthor)
<span class="badge">Prolific Author</span>
@endif
Mistake 2 — Business Logic Inside Routes
// BAD: logic directly in routes/web.php
Route::get('/posts', function () {
$posts = Post::where('is_published', true)
->where('created_at', '>', now()->subDays(30))
->orderBy('views', 'desc')
->get();
return view('posts.index', compact('posts'));
});
This works for a prototype. But you cannot reuse this logic anywhere else. You cannot write a unit test for it easily. When the query grows more complex, your routes file becomes a logic dump that has nothing to do with routing. Routes should point — not execute.
Mistake 3 — Fat Controllers (The Most Common Problem)
This is by far the most frequent MVC violation in real Laravel projects. It happens gradually — you add a little validation here, a business rule there, and three months later your controller method is 80 lines long.
// BAD: fat controller doing everything
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
]);
$post = new Post();
$post->title = $request->title;
$post->content = $request->content;
$post->user_id = auth()->id();
// Business rule mixed into controller
if (auth()->user()->is_admin) {
$post->is_published = true;
} else {
$post->is_published = false;
// Notify moderators
Notification::send(User::moderators()->get(), new PostPendingReview($post));
}
$post->save();
// More logic...
Cache::forget('latest_posts');
activity()->log('Post created: ' . $post->title);
return redirect('/posts')->with('success', 'Post submitted.');
}
This method is doing validation, business logic, data saving, cache clearing, and activity logging all in one place. When requirements change — and they always do — you have to carefully untangle all of this.
The clean version — split the responsibility:
// Controller — handles flow only
public function store(StorePostRequest $request)
{
$post = $this->postService->create($request->validated());
return redirect('/posts')->with('success', 'Post submitted.');
}
// app/Http/Requests/StorePostRequest.php — handles validation
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string',
];
}
// app/Services/PostService.php — handles business logic
class PostService
{
public function create(array $data): Post
{
$data['user_id'] = auth()->id();
$data['is_published'] = auth()->user()->is_admin;
$post = Post::create($data);
if (!$post->is_published) {
Notification::send(User::moderators()->get(), new PostPendingReview($post));
}
Cache::forget('latest_posts');
return $post;
}
}
Now the controller is three lines. The validation lives in a Form Request class. The business logic lives in a Service class. Each piece can be tested independently, reused in other controllers (like an API controller), and changed without touching the others.
The One Rule That Keeps MVC Clean
When you are unsure where a piece of code belongs, ask this question:
- Is it about data or database interaction? → Model (or Service for complex logic)
- Is it about coordinating a request — what to fetch, what to redirect to? → Controller
- Is it about displaying something to the user? → View
If a file starts doing more than one of these things, you are accumulating technical debt. It does not break immediately — and that is exactly why it is dangerous. The cost shows up weeks later when adding a simple feature requires understanding three hundred lines of tangled logic.
What Comes Next
Now that you understand how MVC flows in Laravel, the natural next steps are:
- Laravel Request Lifecycle — understand what happens before the route even receives the request: how middleware, service providers, and the service container work before MVC kicks in
- Eloquent Relationships — learn how Models connect to each other, so you can fetch related data (posts with their authors, orders with their items) cleanly without writing complex queries in controllers