Best Practices
This final lesson doesn't introduce anything new — it pulls together the habits, from every earlier lesson in this track, that separate code that merely "works" from a Laravel application that stays pleasant to maintain a year later. Treat it as a checklist to revisit on real projects, not just a one-time read.
1. Keep Controllers Thin
A controller's job, covered in Controllers, is to receive the request and return a response — not to host business logic. When a controller method grows past a handful of lines of actual decision-making, that logic belongs somewhere more reusable: the model, a dedicated service class, or a Form Request.
<?php
// Thin: the controller delegates, it doesn't decide
public function store(StoreOrderRequest $request)
{
$order = $this->orders->createFromRequest($request->validated());
return redirect()->route('orders.show', $order);
}
- Push validation into a Form Request, not inline
ifchecks in the controller. - Push multi-step business logic into a model method or a small service class.
- A controller method that reads like a short summary of "what happens," not "how," is a good sign.
2. Use Form Requests for Real Validation
As covered in Requests & Validation, any validation beyond a couple of trivial rules belongs in its own Form Request class, not inline in the controller.
php artisan make:request StoreOrderRequest
- Keeps validation rules testable and reusable on their own.
- Authorization (
authorize()) travels with the rules, not scattered separately. - The controller signature alone (type-hinting the Form Request) documents what's expected.
3. Always Eager-Load Relationships
The N+1 query problem, covered in Eloquent
Relationships, is one of the easiest performance mistakes to make and one of the easiest to
avoid — the fix is a single with() call.
<?php
$authors = Author::with('articles')->get(); // 2 queries, not N+1
- Any time you loop over a collection and touch a relationship, ask whether it's eager-loaded.
- In development, tools like Laravel Debugbar make N+1 problems visible immediately — check query counts, not just correctness.
4. Use API Resources, Never Raw Models
As covered in API Resources & REST APIs, returning a model directly from an API endpoint leaks whatever columns happen to exist on the table — including ones that were never meant to be public.
<?php
return ArticleResource::collection(Article::with('author')->get());
- A Resource is the one place that decides the JSON shape — controllers and models don't need to know or care.
- Adding a new column to the table never silently changes your public API response.
5. Push Slow Work Onto Queued Jobs
As covered in Jobs & Queues, anything that doesn't need to finish before the response is sent — sending an email, generating a report, calling a slow external API — belongs on a queue, not inline in the request.
<?php
ProcessPodcastUpload::dispatch($podcast); // returns instantly, work happens in the background
- A request that blocks for 5+ seconds on non-essential work is a queue candidate.
- Keeps the user-facing response fast regardless of how slow the background work is.
6. Use Policies, Not Scattered if Checks
As covered in Authorization: Gates & Policies, model-specific
"can this user do this to this record?" logic belongs in a Policy class, checked consistently via
$this->authorize() or @can, not repeated inline through every controller.
<?php
public function update(Request $request, Article $article)
{
$this->authorize('update', $article);
// ...
}
- One place per model to find and change the authorization rule.
- Consistent everywhere — a controller, a Blade
@candirective, and an API endpoint all defer to the same Policy.
7. Write Feature Tests for Critical Flows
As covered in Testing, you don't need 100% coverage to get real value — but the flows that would genuinely hurt if they silently broke (checkout, registration, login, anything involving money) deserve at least one Feature test each.
<?php
$response = $this->post('/checkout', $validPayload);
$response->assertStatus(200);
- Start with the flows that would be a genuine incident if they broke, not exhaustive coverage everywhere.
- A Feature test that hits the real route is a stronger safety net than a Unit test of an isolated helper.
8. Never Disable CSRF or Use $guarded = [] "To Make It Work"
As covered in Security Best Practices, both of these show up constantly as a quick fix for a confusing error — and both quietly remove a real protection.
- A 419 error means a form is missing
@csrf— add it, don't exempt the route from CSRF verification. - A mass-assignment exception means a column is missing from
$fillable— add that column by name, don't switch to$guarded = [].
9. Bind Interfaces, Not Concrete Classes
As covered in Service Container & DI, when a piece of logic might reasonably be swapped later (a different payment gateway, a fake implementation in tests), depend on an interface and let the service container resolve the concrete class.
<?php
// AppServiceProvider::register()
$this->app->bind(PaymentGateway::class, StripeGateway::class);
- Type-hint the interface in constructors, never the concrete class, when this flexibility matters.
- Makes swapping a real dependency for a test double trivial, without touching the class that uses it.
10. Lean on Laravel's Conventions
Every lesson in this track has followed a consistent thread: Laravel has an opinion about where
things go and how they're named — model names implying table names, {model}_id foreign
keys, resourceful controller method names, the app/ directory structure — and fighting
those conventions for no strong reason usually costs more than it saves.
- Reach for Laravel's built-in solution first (a Form Request, a Policy, a queued Job) before writing a custom one.
- When you do deviate from convention, know why — and expect the next person reading the code to ask.