MasterPHP.in
Laravel Tutorial

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:

  1. Receives the request
  2. Asks the Model for data
  3. 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

Frequently Asked Questions

Laravel provides the structure — folders for Models, Controllers, and Views — but it does not prevent you from violating MVC. You can write a query inside a Blade file and Laravel will not stop you. MVC is a discipline, not a technical restriction. The framework makes it easy to follow; you have to choose to follow it.
A Service class is a plain PHP class that holds business logic that does not belong in a Controller or a Model. When your controller method grows beyond fetching data and returning a view — when it starts making decisions, sending notifications, updating multiple models — that logic belongs in a Service. There is no Artisan command for creating services; you create the class manually in app/Services/.
For simple queries, yes — Post::all() or User::find($id) in a controller is perfectly acceptable and common. The problem starts when queries become complex, contain business rules, or are duplicated across multiple controller methods. That is the signal to move the logic to the Model (as a scope or method) or to a Service class.
Both validate input, but Form Requests keep your controllers clean by moving the validation rules into a dedicated class. A Form Request is generated with php artisan make:request StorePostRequest. It also handles authorization checks. For simple forms with one or two rules, inline validation is fine. For complex forms or validation you want to reuse, a Form Request is cleaner.

Share this tutorial