Building applications with Laravel is famously developer-friendly, but as codebases grow in traffic, team size, and complexity, adhering to rigorous engineering standards makes the difference between an agile system and technical debt. In this comprehensive guide, we unpack modern Laravel best practices covering architecture, database performance, asynchronous workloads, and security hardening.
1. Architectural Clarity & Slim Controllers
A frequent anti-pattern in growing Laravel applications is the "Fat Controller", where validation, domain logic, authorization, notification triggers, and response formatting all collide within a single controller method. This tightly couples HTTP transport concerns to business rules, making testing and reuse difficult.
Adopt Single-Action Invokable Classes (Actions)
Encapsulate distinct business operations into dedicated Action classes. An action should do one job, be easily testable in isolation, and communicate clear intent:
<?php
declare(strict_types=1);
namespace App\Actions\Orders;
use App\DTOs\OrderData;
use App\Events\OrderPlaced;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
final class CreateOrderAction
{
public function execute(OrderData $data): Order
{
return DB::transaction(function () use ($data): Order {
$order = Order::create([
'user_id' => $data->userId,
'total_cents' => $data->totalCents,
'status' => 'pending',
]);
$order->items()->createMany($data->items);
OrderPlaced::dispatch($order);
return $order;
});
}
}
Your HTTP controller then remains ultra-light, responsible solely for delegating input and formatting the HTTP response:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Orders;
use App\Actions\Orders\CreateOrderAction;
use App\Http\Requests\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final class StoreOrderController
{
public function __invoke(StoreOrderRequest $request, CreateOrderAction $action): JsonResponse
{
$order = $action->execute($request->toDto());
return (new OrderResource($order))
->response()
->setStatusCode(Response::HTTP_CREATED);
}
}
Dedicated Form Requests & Data Transfer Objects (DTOs)
- Form Requests: Keep controllers clean by placing validation rules and authorization checks (
authorize()) inside custom Form Request classes. - Readonly DTOs: Use PHP 8.2+
readonlyclasses to pass strongly-typed payloads between layers instead of loosely typed associative arrays.
2. Eloquent & Database Performance
Database queries are typically the most significant bottleneck in web applications. Writing efficient Eloquent code requires understanding what queries are executed under the hood.
Prevent the N+1 Query Problem
Never iterate over Eloquent relations without eager loading. In local and staging environments, instruct Laravel to strictly forbid lazy loading by adding this to your AppServiceProvider:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Throws an exception in dev/testing if lazy loading is detected
Model::preventLazyLoading(! app()->isProduction());
}
When fetching related models, explicitly load them using with() or loadMissing():
// Bad: Triggers 100 extra queries for 100 posts
$posts = Post::latest()->take(100)->get();
// Good: Fetches all data in exactly 2 optimized SQL queries
$posts = Post::with(['user:id,name,email', 'categories:id,name,slug'])
->latest()
->take(100)
->get();
Memory-Safe Batch Processing with chunkById() and lazy()
When running data migrations, sending bulk emails, or processing large result sets, never load millions of records with Model::all() or Model::get(). Doing so will exhaust PHP's memory limit.
Instead, use chunkById() or lazy collections (lazyById()), which use primary key pagination to stream results efficiently:
User::where('is_active', true)
->chunkById(500, function ($users) {
foreach ($users as $user) {
// Process user with bounded memory footprint
$user->calculateLoyaltyPoints();
}
});
Pro-tip: Prefer
chunkById()overchunk()when updating records within the callback. Standardchunk()usesOFFSETpagination, which can skip records if the underlying status column is mutated during iteration.
3. High-Throughput Caching Strategies
Strategic caching reduces database load and cuts Time to First Byte (TTFB) significantly.
Atomic Locks for Race Condition Prevention
When multiple concurrent web requests or background workers might execute the same critical section simultaneously (such as generating an expensive report or processing a payment credit), use distributed atomic locks:
use Illuminate\Support\Facades\Cache;
$lock = Cache::lock('process-payout-user-'.$user->id, 10);
if ($lock->get()) {
try {
// Execute sensitive transactional logic
$user->processMonthlyPayout();
} finally {
$lock->release();
}
} else {
// Another process is already running this task
abort(409, 'Payout operation is currently in progress.');
}
Cache Tagging and Granular Invalidation
If using Redis or Memcached, organize cached fragments with tags so you can invalidate related groups of keys simultaneously without flushing your entire application cache:
// Store tagged cache item
Cache::tags(['posts', 'author:'.$authorId])->remember("post:{$slug}", 3600, function () use ($slug) {
return Post::where('slug', $slug)->firstOrFail();
});
// Invalidate all posts by this author instantly
Cache::tags(['author:'.$authorId])->flush();
4. Resilient Background Queue Architecture
Long-running operations such as outbound HTTP requests, PDF generation, and image resizing should never block web requests. Offload them to background queue workers.
Idempotent Job Design
Queues can fail or retry. Always design queued jobs to be idempotent—running the same job multiple times must produce the exact same outcome without duplicate side effects (e.g. charging a customer twice).
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
final class SendInvoiceEmailJob implements ShouldQueue
{
use Queueable;
// Retry 3 times with exponential backoff before failing
public int $tries = 3;
public array $backoff = [30, 120, 300];
public function __construct(public Invoice $invoice) {}
public function handle(): void
{
// Guard against duplicate execution
if ($this->invoice->email_sent_at !== null) {
return;
}
// Deliver email via mail provider
Mail::to($this->invoice->user->email)->send(new InvoicePaidMail($this->invoice));
$this->invoice->updateQuietly(['email_sent_at' => now()]);
}
}
5. Security & Defense-in-Depth
Laravel provides robust out-of-the-box security primitives, but application-level configurations require intentional vigilance.
Guarded Mass Assignment
Always specify $fillable explicitly on Eloquent models rather than setting $guarded = []. This prevents untrusted user input from overwriting sensitive columns like role, is_admin, or balance.
Scoped Route Model Binding
When working with nested resource routes (such as /users/{user}/posts/{post}), ensure that child models belong to the parent resource to avoid IDOR (Insecure Direct Object Reference) vulnerabilities:
use App\Http\Controllers\UserPostController;
Route::get('/users/{user}/posts/{post:slug}', [UserPostController::class, 'show'])
->scopeBindings();
Strict Rate Limiting
Protect authentication routes, password reset endpoints, and public search APIs against brute-force attacks by applying customized rate limiters in your route definitions:
Route::middleware(['throttle:auth-attempts'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::post('/forgot-password', [AuthController::class, 'sendResetLink']);
});
6. Code Quality, Strict Typing & Testing
Maintainable applications rely on automated guardrails that catch regressions before they reach production.
- Strict Typing: Add
declare(strict_types=1);at the top of every PHP file to leverage PHP 8.2+ type safety and avoid subtle coercion bugs. - Static Analysis: Integrate PHPStan (or Larastan) at Level 8 or Max in your CI/CD pipeline to catch unhandled null values and type mismatches.
- Code Styling: Run
./vendor/bin/pintto automatically enforce PSR-12 and Laravel code style consistency. - Integration Testing: Write comprehensive feature tests using Pest PHP or PHPUnit to assert HTTP responses, database states, and queued event dispatches.
Summary Checklist
| Area | Best Practice | Impact |
|---|---|---|
| Architecture | Single-action classes & Form Requests | Decoupled logic, easy unit testing |
| Database | Model::preventLazyLoading() & chunkById() |
Zero N+1 queries, minimal memory usage |
| Caching | Atomic locks & tagged Redis invalidation | Race condition immunity, fast TTFB |
| Queues | Idempotent jobs & exponential backoffs | Fault-tolerant asynchronous execution |
| Security | Explicit $fillable & scoped route bindings |
Protection against IDOR & mass assignment |
By incorporating these patterns into your daily workflow, your Laravel applications will remain resilient, performant, and enjoyable to maintain as they scale.