Laravel Best Practices for Maintainable Applications (2026)
Laravel best practices for maintainable apps: structure, validation, Eloquent N+1 fixes, queues, testing, config caching, security and painless upgrades.

The Laravel best practices that matter most for long-lived applications are not clever tricks. They are conventions: thin controllers, validation in Form Requests, eager-loaded Eloquent relationships, slow work pushed to queues, a real test suite, cached configuration in production and staying on a supported version. This guide collects the habits we rely on when building and maintaining Laravel apps, with code you can copy.
As of September 2026, the current major version is Laravel 13, released in March 2026 and requiring PHP 8.3 or newer.
1. Follow the framework’s structure first
Laravel’s default folders exist for a reason. New developers on your team — and your future self — know where to look for routes, models, jobs and policies. Resist inventing a custom architecture on day one.
As the app grows, add a few well-named layers rather than a full “enterprise” pattern:
app/Http/Requests— validation and authorisation for incoming requests.app/Actions(orapp/Services) — single-purpose classes for business operations, such asCreateInvoiceorCancelSubscription.app/Jobs— work that runs on queues.app/Policies— authorisation rules per model.
Keep controllers thin: receive the request, call an action, return a response.
class InvoiceController extends Controller
{
public function store(StoreInvoiceRequest $request, CreateInvoice $createInvoice)
{
$invoice = $createInvoice->handle($request->user(), $request->validated());
return redirect()->route('invoices.show', $invoice);
}
}
The same CreateInvoice action can then be called from an Artisan command, a queued job or an API controller without duplicating logic.
2. Validate with Form Requests
Inline $request->validate() is fine for tiny forms, but Form Requests keep rules, authorisation and custom messages together and make controllers readable.
class StoreInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Invoice::class);
}
public function rules(): array
{
return [
'customer_id' => ['required', 'exists:customers,id'],
'due_date' => ['required', 'date', 'after:today'],
'lines' => ['required', 'array', 'min:1'],
'lines.*.description' => ['required', 'string', 'max:255'],
'lines.*.amount' => ['required', 'numeric', 'min:0'],
];
}
}
Always use $request->validated() (or safe()) when writing to models, never $request->all(). Combined with explicit $fillable on models, it prevents mass-assignment bugs.
3. Eloquent performance: kill N+1 queries
The most common Laravel performance problem we are asked to fix is the N+1 query: loading a list, then triggering one extra query per row when accessing a relationship.
// N+1: one query for posts, plus one per post for the author
$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
echo $post->author->name;
}
// Fixed: two queries in total
$posts = Post::with('author')->latest()->take(50)->get();
Make N+1 bugs fail loudly during development:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::shouldBeStrict(! $this->app->isProduction());
}
shouldBeStrict() enables lazy-loading prevention, prevents silently discarding unfillable attributes and throws when accessing missing attributes — outside production only.
Other Eloquent habits:
- Use
withCount()andwithSum()instead of loading whole relations to count them. - Select only needed columns on large tables:
User::select('id', 'name'). - Process big datasets with
chunkById()orlazyById()rather thanall(). - Add database indexes for columns you filter, join and sort on; check slow queries with
EXPLAIN. - Use Laravel Telescope or Debugbar locally to see query counts per page.
4. Push slow work to queues
Sending emails, generating PDFs, calling external APIs and processing uploads should not block the HTTP request. Dispatch a job and return immediately.
class GenerateInvoicePdf implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 120;
public function __construct(public Invoice $invoice) {}
public function backoff(): array
{
return [10, 60, 300];
}
public function handle(PdfRenderer $renderer): void
{
$renderer->renderInvoice($this->invoice);
}
}
GenerateInvoicePdf::dispatch($invoice);
Queue best practices:
- Use Redis or a database queue in production, not
sync. - Set
tries,timeoutandbackoffon every job, and monitor the failed jobs table. - Make jobs idempotent — safe to run twice — because retries happen.
- Run workers under a process manager (Supervisor, systemd) and restart them on deploy with
php artisan queue:restart. - Use Horizon if you are on Redis and want a dashboard.
On shared hosting without long-running processes, a cron-driven queue:work --stop-when-empty is a workable compromise; our guide to deploying Laravel to cPanel covers this.
5. Write tests that protect the business
You do not need 100% coverage. You need tests around the things that would hurt if they broke: sign-up, payments, permissions, invoicing, integrations. Laravel ships with support for Pest and PHPUnit, and feature tests that hit real routes give the best return.
it('prevents users from viewing other users invoices', function () {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$invoice = Invoice::factory()->for($owner)->create();
$this->actingAs($intruder)
->get(route('invoices.show', $invoice))
->assertForbidden();
});
Tips:
- Use factories and the
RefreshDatabasetrait for isolated tests. - Fake external services with
Http::fake(),Mail::fake(),Queue::fake(). - Run the suite in CI on every pull request. See GitHub Actions push-to-deploy for a pipeline example.
- Add a static analyser such as Larastan and a formatter such as Pint.
6. Configuration and caching
- Read
env()only inside config files. Everywhere else, useconfig('services.stripe.key'). Once configuration is cached,env()calls outside config returnnull. - In production, run
php artisan optimizeduring deployment to cache config, routes, events and views. - Set
APP_DEBUG=falseandAPP_ENV=productionin production. - Use the cache for expensive, repeatable reads:
Cache::remember('stats:today', 600, fn () => ...), and invalidate on writes. - Never commit
.env. Keep a complete.env.example.
7. Security essentials
Laravel gives you strong defaults; most security bugs come from bypassing them.
- Authorisation everywhere: use policies and
canchecks for every action on a model, not just hiding buttons in the UI. - Mass assignment: define
$fillableand pass validated data only. - SQL injection: use Eloquent or query-builder bindings; be careful with
whereRaw,orderByRawand user-supplied column names. - XSS: Blade’s
{{ }}escapes output; use{!! !!}only for trusted, sanitised HTML. - CSRF: keep the protection middleware enabled for web routes.
- Rate limiting: apply
throttlemiddleware to login, password reset and API routes. - Secrets: store API keys in environment variables or a secrets manager; rotate them if leaked.
- Dependencies: run
composer auditregularly.
Our broader web app security checklist goes further.
8. Plan upgrades instead of fearing them
Laravel releases a major version roughly once a year. Per the official support policy, each release gets 18 months of bug fixes and two years of security fixes. As of September 2026, Laravel 12 receives security fixes only (until February 2027), and Laravel 11 and older are out of support.
Make upgrades routine:
- Upgrade one major version at a time and read the official upgrade guide.
- Keep dependencies current with small, frequent
composer updateruns. - Rely on your test suite to catch regressions — this is where tests pay back.
- Tools like Laravel Shift can automate much of the mechanical work.
- Keep PHP itself on a supported version; Laravel 13 needs PHP 8.3+.
If you have inherited an app several versions behind, read fix or rebuild a legacy web app before deciding what to do.
Laravel best practices checklist
- Default structure, plus actions, Form Requests, jobs and policies as the app grows
- Thin controllers; business logic in reusable classes
- Validation in Form Requests; only validated data written to models
- Eager loading everywhere;
shouldBeStrict()in development - Indexes on filtered and joined columns
- Slow work on queues, with retries, timeouts and idempotent jobs
- Feature tests for critical flows, run in CI
-
env()only in config;php artisan optimizeon deploy - Policies, rate limits and
composer audit - On a supported Laravel and PHP version, with a yearly upgrade plan
Need a hand with your Laravel app?
Whether you are starting a new Laravel project or trying to tame an existing one, we can review the code, fix performance and security issues, and set up tests and deployments. Take a look at our web application development service or contact us with a short description of your app.