If you have ever felt lost in your own Laravel codebase or stared at a UserController
method stretching hundreds of lines trying to decipher what's going on, you are not alone.
As Laravel developers, we've all been there. The good news is that Laravel's foundational Model-View-Controller (MVC) pattern is a crucial tool for helping you escape the spaghetti code jungle and build clean, scalable applications.
It is important to realize that understanding the MVC is more than an academic exercise. Doing so is a crucial skill that directly impacts your code's maintainability and your team's productivity. Let's demystify the MVC pattern and learn how to write cleaner Laravel code.
What is MVC, Anyway? (A Quick Refresher)
At its core, MVC divides your application into three interconnected parts, each with a specific responsibility:
-
Model (M): This is our data layer. Models represent your application's data and the business logic that interacts with it. In Laravel, this usually means your Eloquent Models, which communicate with your database tables.
- Responsibility: Data storage, retrieval, validation, and manipulation.
-
Example: A
Product
model handles everything related to product data (fetching, saving, updating, relationships, etc,.).
-
View (V): This is the presentation layer. As their name implies, views are responsible for displaying data to the user. They are the front-facing part of your website or software built using Laravel. In Laravel, these are typically Blade templates (
.blade.php
files).- Responsibility: Displaying data received from the Controller and handling the user interface.
-
Example: A
products/index.blade.php
view displays a list of products.
-
Controller (C): This is the orchestrator or coordinator . Controllers handle incoming user requests, interact with Models to fetch or process data, and then pass that data to the appropriate View for display.
- Responsibility: Receiving requests, coordinating between Model and View, handling user input, redirecting to appropriate pagesand much more.
-
Example: A
ProductController
would handle requests like "show all products," "create a new product," or "update a product."
Each of these parts can be much more complex than we have outlined here. This is just a general idea of what they are, what they are responsible for, and how you can use them.
As you develop more Laravel websites and build software using this framework, you will find new and interesting ways to use these parts and additional ways of getting them to work together in new ways.
Why Does MVC Matter for Clean Code?
The beauty of MVC, and perhaps the most important reason for following this pattern, is its separation of concerns. Each of the components we have mentioned above has a single, well-defined job. This separation leads to:
- Easier Maintenance: When a bug arises or a feature needs to be changed, you know exactly where to look. Is it a data issue? Check the Model. Is it a display issue? Check the View. Is it a request handling issue? Check the Controller.
- Better Organization: Your codebase remains structured and predictable, making it easier for new team members (or your future self!) to understand.
- Enhanced Testability: Because components are independent, you can test them in isolation more effectively.
- Improved Scalability: As your application grows, the distinct roles of MVC components prevent code entanglement and allow for more manageable expansion.
Practical Tips for Cleaner MVC in Laravel
Now, let's dive into practical ways to ensure your Laravel MVC components stay lean and focused.
1. Keep Your Controllers Thin (The "Fat Model, Thin Controller" Principle)
This is perhaps the most important rule for clean Laravel code. Your controllers should primarily act as traffic cops: receiving requests, delegating tasks, and returning responses. They should not contain complex business logic.
Bad Example (Fat Controller):
// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function store(Request $request)
{
// Bad: Controller handles too much logic
$validatedData = $request->validate([
'name' => 'required|max:255',
'price' => 'required|numeric',
'description' => 'nullable',
'image' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,svg', 'max:2048'],
]);
if ($validatedData['price'] < 0) {
return redirect()->back()->withErrors('Price cannot be negative.');
}
$product = new Product();
$product->name = $validatedData['name'];
$product->price = $validatedData['price'];
$product->description = $validatedData['description'];
$product->image = $validatedData['image'];
$product->save();
// More logic could go here...
return redirect()->route('products.index')->with('success', 'Product created successfully!');
}
}
Good Example (Thin Controller with Logic in Model/Service):
PHP
// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;
use App\Models\Product;
use App\Http\Requests\ProductStoreRequest; // Using Form Request for validation
class ProductController extends Controller
{
public function store(ProductStoreRequest $request) // Type-hinting Form Request
{
// Good: Controller delegates creation logic to the Model
$product = Product::createProduct($request->validated()); // Static method on Model
return redirect()->route('products.index')->with('success', 'Product created successfully!');
}
}
Did you notice how the good example is significantly cleaner and easier to read? The controller's job is simply to initiate the action and return a response.
A Special Note on Form Requests
You might have noticed ProductStoreRequest
in our "good" example controller. This isn't just a regular Request object; it's a Form Request!
Laravel allows you to create dedicated form requests using the code below:
php artisan make:request <name of form request object>
NB: It is good practice to end your form rerquest objects name with the word Request, e.g ProductStoreRequest
.
This request object handles two critical concerns before your data even touches your controller method:
- Validation: All your input validation rules live here. If validation fails, Laravel automatically redirects back or sends a JSON response. If you are using a form request when submitting a form, you can also use it to show the different validation errors that have come up after submitting the form
- Authorization: You can define logic to check if the current user is authorized to perform the action. For example, you might not want a typical user to edit or delete a product; you might want to reserve that function for an admin!
Here's what your ProductStoreRequest
might look like:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; // For authorization checks
class ProductStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
if(Auth::check())
{
return true;
}
// If no authorization passes (see below for additonal options), return false
// By default, this will deny creation unless the user is explicitly authorized above
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255', 'unique:products,name'],
'description' => ['nullable', 'string', 'max:1000'],
'price' => ['required', 'numeric', 'min:0.01'],
'image' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,svg', 'max:2048'],
];
}
/**
* Get the error messages for the defined validation rules.
* Optional: Customize the error messages the user will see when they submit a form with incorrect data.
* This is usually very useful if you find that the default error messages aren't clear enough.
*
* @return array
*/
public function messages(): array
{
return [
'name.required' => 'A product name is required.',
'name.unique' => 'A product with this name already exists.',
'price.min' => 'The price must be at least :min.',
'image.max' => 'The image may not be larger than 2MB.',
];
}
/**
* Prepare the data for validation.
* Optional: Use this method to modify request data before validation.
* For example, trimming strings or setting default values.
*
* @return void
*/
protected function prepareForValidation(): void
{
$this->merge([
// Ensure price is float and not string
'price' => (float) $this->input('price'),
// Trim whitespace from string inputs
'name' => trim($this->input('name')),
'description' => trim($this->input('description')),
]);
}
}
The option I have added above in the authorize
method is a simple check if a user is authenticated. Since it will evaluate to true if a user is logged in, any logged-in user can create a product.
A second option would be to check for a specific role or permission. This is a common pattern for product creation and similar patterns.
You would typically have a roles/permissions package like Spatie Laravel Permission or a simple 'is_admin' column on the database or your User model.
// Example with a simple 'is_admin' flag on the User model
if (Auth::check() && Auth::user()->is_admin) {
return true;
}
// Example with a permissions package (e.g., Spatie Laravel Permission)
if (Auth::check() && Auth::user()->can('create products')) {
return true;
}
By offloading all the above responsibilities to Form Requests, your controllers remain incredibly clean and focused solely on coordinating the request.
2. Empower Your Models (The "Fat Model" Part)
Your Eloquent Models are more than just database table representations. They are the ideal place for:
Business Logic related to the data: Calculations, status changes, complex validations.
Relationship definitions:
hasMany
,belongsTo
,belongsToMany
, etc.Scopes: Reusable query constraints.
Mutators and Accessors: Formatting data when setting or retrieving attributes.
Continuing from the example, in App\Models\Product.php
:
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected $fillable = [
'name',
'price',
'description',
'image'
];
/**
* Create a new product with basic validation.
* This method holds the creation logic.
*
* @param array $data
* @return Product
* @throws \InvalidArgumentException
*/
public static function createProduct(array $data): Product
{
if ($data['price'] < 0) {
throw new \InvalidArgumentException('Price cannot be negative.');
}
return self::create($data); // Eloquent's create method
}
/**
* Define a scope to get active products.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}
While Models are excellent for data-specific logic, sometimes you'll encounter business logic that spans multiple models or involves a complex sequence of operations.
For these scenarios, consider introducing Service Classes. A service class is a plain PHP class where you encapsulate complex business rules. Your controller would then call a method on this service, which in turn interacts with your Models.
This keeps both your controllers and models focused on their primary responsibilities, offering another layer of abstraction for truly complex flows.
3. Keep Views Dumb (They Just Display)
Views should primarily focus on displaying the data they receive from the controller. Avoid any complex logic, database queries, or heavy computations within your Blade templates. Keep them as pure presentation layers as possible.
Bad Example (Logic inside the View):
{{-- resources/views/products/show.blade.php --}}
<h1>{{ $product->name }}</h1>
<p>{{ $product->description }}</p>
@if ($product->price > 100) {{-- Logic to determine discount --}}
<p>Price: ${{ $product->price * 0.9 }} (10% off!)</p>
@else
<p>Price: ${{ $product->price }}</p>
@endif
{{-- Avoid doing things like this in the view: --}}
@php
$relatedProducts = App\Models\Product::where('category_id', $product->category_id)->get();
@endphp
<ul>
@foreach ($relatedProducts as $related)
<li>{{ $related->name }}</li>
@endforeach
</ul>
Good Example (Logic in Controller/Model, View for Display):
Logic in the controller
// app/Http/Controllers/ProductController.php
class ProductController extends Controller
{
public function show(Product $product)
{
// Calculate discounted price in controller or (better) model accessor
$displayPrice = $product->price;
if ($product->price > 100) {
$displayPrice = $product->price * 0.9;
}
// Fetch related products in the controller and pass to view
$relatedProducts = Product::where('category_id', $product->category_id)
->where('id', '!=', $product->id)
->limit(3)
->get();
return view('products.show', compact('product', 'displayPrice', 'relatedProducts'));
}
}
View for display
{{-- resources/views/products/show.blade.php --}}
<h1>{{ $product->name }}</h1>
<p>{{ $product->description }}</p>
<p>Price: ${{ number_format($displayPrice, 2) }}
@if ($product->price > 100)
(10% off!)
@endif
</p>
@if ($relatedProducts->isNotEmpty())
<h2>Related Products</h2>
<ul>
@foreach ($relatedProducts as $related)
<li><a href="{{ route('products.show', $related) }}">{{ $related->name }}</a></li>
@endforeach
</ul>
@endif
Notice how the clear separation of code makes each part instantly understandable and readable.
The view simply takes the data it's given and displays it, without making complex decisions or calling the database.
Next Steps: Apply These Principles!
Mastering MVC is a journey, not a destination. By consistently applying these principles, you'll transform your Laravel development experience, leading to more enjoyable coding sessions and more robust applications.
Challenge yourself:
- Review one of your existing Laravel controllers. Can you move some of its logic into a Model or a custom Form Request?
- Look at your Blade templates. Are there any complex computations or database queries that could be moved?
What's your biggest challenge or 'aha!' moment when it comes to organizing your Laravel code? Share your insights and tips in the comments below!
This is a continuing series. Stay tuned for the next article in this series, where we'll dive into Laravel's Routing System and show how to effectively guide requests through your newly organized application!
Edit: The next part in this series, Laravel's Routing System: Beyond the Basics, is now live.
Top comments (6)
I think it is a good start.
I think the thin controller fat model credo is a thing of the past. We are in the fat drug era so everything has to be thin.
A quick way to do it is to create a helper.
In the controller the code would be.
The same thing can be done for the view. There the goal is to prepare the data for the view.
I would even add the reduction percentage to the prepare function, because
@if ($product->price > 100)
is too tight of a coupling with the backend logic.This is really great! Thanks David.
The thing I love about Laravel is that there are so many ways to reach the same destination, some arguably better than others, some personal preferences.
The helper approach is something I had not thought about and is one I will consider using more of.
I am not that found of the Lavavel helpers because they are just hiding things from you in the name of developer experience.
Most developers would create a service instead of a helper because that is the object-oriented way.
If the function isn't to big, i wouldn't bother, a function it is as testable as a class method.
If it is something I can do in a "Laravel way", for example using the many helpers the framework has, I wouldn't bother with a helper.
A service class is much better as mentioned in the article. But if I need something so custom that it is not covered by the typical patterns (can't think of any right now), I could reach out for a helper.
Super clear breakdown, especially about fat models and thin controllers. Did you ever run into edge cases where you had to bend the MVC rules a bit?
Yeah, I have thrown a query inside a view when I wanted the first blog post to have a different layout than the others. The design was one large post with a large thumbnail and big text at the top, and a column of four article cards below that.
Something like this:
I know this is not an edge case in the traditional sense, but I didn't know any better at the time.
Now I know how to do that in a better way, particularly using
first
in the view, but I considered that an edge case at the time.