Laravel Best Practices 2026: Code, Database, Security & Queues

Laravel best practices that hold up in production: structure, Eloquent vs raw queries, migrations, security, queues with Supervisor and Docker, caching, testing and deployment.

Syed Hamid December 4, 2025 Updated October 15, 2026 22 min read
Laravel Best Practices 2026: Code, Database, Security & Queues
On this page
  1. 1. Project Structure
  2. 2. Eloquent and Database Queries
  3. 3. Migrations
  4. 4. Validation and Authorization
  5. 5. Security Best Practices
  6. 6. Configuration and Environment
  7. 7. Queues and Background Work
  8. 8. Caching and Performance
  9. 9. Testing
  10. 10. Code Quality
  11. 11. Deployment
  12. 12. Keep Laravel Up to Date
  13. Common Mistakes I See in Laravel Projects
  14. Applying This to Your Project
  15. Need Help With a Laravel Project?

Laravel makes it easy to build something that works. Building something that still works, and is still pleasant to change, two years and fifty thousand users later is a different skill. Most of the Laravel projects I am asked to rescue did not fail because of Laravel; they failed because of a handful of habits that seemed harmless early on.

This guide collects the practices I use on production Laravel applications, from SaaS platforms to marketplaces, after over ten years of PHP and Laravel work. It applies to Laravel 11, 12 and later, and every section is practical: what to do, why, and the code that shows it.

The 10 rules that matter most
  • Keep controllers thin; put business logic in action or service classes.
  • Validate in Form Requests and authorize with Policies, on every write.
  • Turn on strict mode (Model::shouldBeStrict()) in development to catch N+1 queries and silent bugs.
  • Eager load relationships; index every column you filter, sort or join on.
  • Use raw SQL when it is clearer or faster, but always with bindings.
  • Never edit a migration that has run in production; add a new one.
  • Call env() only inside config files.
  • Move anything slow (email, PDFs, API calls) to queues, run by Supervisor or a container.
  • Test the paths that cost money when they break, including authorization.
  • Automate quality: Pint, Larastan and tests in CI on every pull request.

1. Project Structure

Follow the framework's conventions

Laravel's conventions (where models, controllers, requests, jobs and policies live, how they are named) are a shared language. Any Laravel developer can open a conventional project and find their way around in minutes. Fight the conventions only with a very good reason.

Since Laravel 11, the application skeleton is slimmer: middleware, exception handling and routing are configured in bootstrap/app.php, and many files that used to exist by default are only created when you need them. Do not recreate the old structure out of habit.

Keep controllers thin

A controller should receive the request, call the code that does the work, and return a response. When a controller method grows past a screen, business logic has leaked into it.

// app/Actions/PlaceOrder.php
final class PlaceOrder
{
    public function __construct(private PaymentGateway $payments) {}

    public function handle(User $user, Cart $cart): Order
    {
        return DB::transaction(function () use ($user, $cart) {
            $order = $user->orders()->create([...]);
            $this->payments->charge($order);
            OrderPlaced::dispatch($order);

            return $order;
        });
    }
}

// Controller
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
    $order = $placeOrder->handle($request->user(), $request->cart());

    return to_route('orders.show', $order);
}

Action classes like this are easy to test, reuse from a console command or a job, and find later.

Organise large applications by domain

When an application grows beyond a few dozen models, group code by business area (Billing, Catalog, Accounts) rather than having hundreds of files in flat folders. A modular structure like this also makes it possible to split a module into its own service later if it ever needs to, as I explain in choosing an enterprise tech stack.

The repository pattern: usually skip it

Wrapping every Eloquent model in a repository interface is a common pattern carried over from other frameworks. With Eloquent it mostly adds files, not value. Prefer:

  • Query scopes for reusable conditions (Order::paid()->recent())
  • Dedicated query classes for complex reports
  • Action classes for writes

A repository is worth it when you need to swap the underlying source, for example an external API and a local cache behind the same interface.

2. Eloquent and Database Queries

Turn on strict mode in development

// AppServiceProvider::boot()
Model::shouldBeStrict(! app()->isProduction());

This makes Laravel throw an exception when you lazy load a relationship (the cause of N+1 queries), fill an attribute that is not fillable, or read an attribute that was not selected. These are bugs you want to find on your laptop, not in production logs.

Eager load relationships

// N+1: one query for orders, then one per order for the customer
$orders = Order::latest()->take(50)->get();

// 2 queries in total
$orders = Order::with('customer:id,name')
    ->withCount('items')
    ->latest()
    ->take(50)
    ->get();

Select only the columns you need for large tables, and use withCount, withSum and withExists instead of loading whole relationships to count them.

Index what you query

Every column used in where, orderBy or a join on a large table needs an index, and multi-column filters often need a composite index in the right order. foreignId()->constrained() adds both the foreign key and its index. Check slow queries with EXPLAIN; most "Laravel is slow" problems are missing indexes.

Process large data sets in chunks

// Loads everything into memory: avoid on large tables
User::all()->each(...);

// Constant memory, safe even if rows are updated while iterating
User::where('active', true)->chunkById(500, function ($users) {
    // ...
});

// Or as a lazy collection
foreach (User::where('active', true)->lazyById(500) as $user) {
    // ...
}

Raw queries and joins: when and how

Eloquent is ideal for working with records. For reporting, aggregates across large tables and complex joins, the query builder or raw SQL is often clearer and much faster. The one rule that cannot be broken: user input goes in bindings, never in the SQL string.

// Safe: values are bound
$rows = DB::select(
    'select c.id, c.name, sum(o.total) as revenue
       from customers c
       join orders o on o.customer_id = c.id
      where o.paid_at between ? and ?
      group by c.id, c.name
      order by revenue desc
      limit 20',
    [$from, $to]
);

// Query builder join with a raw expression, still bound
$top = DB::table('orders')
    ->join('customers', 'customers.id', '=', 'orders.customer_id')
    ->selectRaw('customers.name, sum(orders.total) as revenue')
    ->whereRaw('orders.total > ?', [$minimum])
    ->groupBy('customers.name')
    ->get();

// Never: SQL injection
DB::select("select * from users where email = '$email'");

Column and table names cannot be bound, so if a user chooses a sort column, check it against a whitelist first.

Use transactions for multi-step writes

Wrap writes that must succeed or fail together in DB::transaction(). For operations that compete for the same rows (stock levels, account balances), lock them with lockForUpdate() inside the transaction.

Database configuration

  • Keep MySQL strict mode on (the default) so invalid data fails loudly instead of being silently truncated.
  • Use utf8mb4 so emoji and all languages store correctly.
  • Store money as integers (cents) or with a decimal cast, never as floats.
  • Store timestamps in UTC; convert to the user's time zone only for display.
  • On larger systems, configure separate read and write connections in config/database.php to use read replicas.

3. Migrations

  • Never edit a migration that has run in production. Add a new migration to change the schema; edited migrations leave environments out of sync.
  • One concern per migration, with a working down() method where reversal is realistic.
  • Plan for zero-downtime changes on big tables: add a nullable column, deploy code that writes to both, backfill in a queued job, then make it required and remove the old column in a later release.
  • Keep data fixes out of schema migrations where possible; use a one-off command or job you can run, test and monitor.
  • Squash old migrations with php artisan schema:dump --prune once there are hundreds, to keep fresh installs and test runs fast.
  • Use factories and seeders so every developer and the test suite can build realistic data in one command.

4. Validation and Authorization

Validate in Form Requests

class StoreProjectRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Project::class);
    }

    public function rules(): array
    {
        return [
            'name'      => ['required', 'string', 'max:120'],
            'budget'    => ['nullable', 'integer', 'min:0'],
            'client_id' => ['required', Rule::exists('clients', 'id')
                ->where('team_id', $this->user()->team_id)],
        ];
    }
}

// Only validated fields reach the model
$project = Project::create($request->validated());

Note the exists rule scoped to the user's team. Without it, a user could attach a record that belongs to someone else, one of the most common real-world Laravel vulnerabilities.

Authorize with Policies, not scattered if-statements

Put "can this user do this to this record" in a Policy and call it from controllers ($this->authorize('update', $project)), Blade (@can) and API resources. One place to read, one place to test.

5. Security Best Practices

  • Mass assignment: define $fillable on every model, and pass $request->validated(), never $request->all().
  • Scope every lookup to the current user or tenant: $request->user()->invoices()->findOrFail($id), not Invoice::findOrFail($id).
  • Output escaping: {{ }} escapes; use {!! !!} only for HTML you have sanitised yourself.
  • CSRF protection stays on for all web forms; exclude only webhook endpoints that verify their own signatures.
  • Passwords: use Password::defaults() with sensible rules and rate-limit login attempts.
  • Encrypt sensitive columns with the encrypted cast; use signed URLs for links that grant access without login.
  • Production settings: APP_DEBUG=false, a unique APP_KEY, HTTPS only, secure and HTTP-only cookies.
  • Dependencies: run composer audit in CI and keep Laravel on a supported version.
  • Rate limiting on APIs, login, password reset and any endpoint that sends emails or SMS.

A fuller checklist is in web application security, and API-specific rules in API development.

6. Configuration and Environment

// config/services.php
'stripe' => [
    'secret' => env('STRIPE_SECRET'),
],

// Anywhere else in the app
$secret = config('services.stripe.secret');   // correct
$secret = env('STRIPE_SECRET');               // returns null once config is cached

Never commit .env. Keep .env.example up to date with every key (without real values) so a new developer can get running quickly.

7. Queues and Background Work

Anything that does not need to finish before the user sees a response belongs in a queue: emails, PDFs, image processing, webhooks, third-party API calls and imports. Pages stay fast, and failures retry automatically.

Write jobs that can safely run twice

  • Pass IDs or models to jobs, not large data blobs.
  • Make jobs idempotent: check whether the work is already done before doing it, because retries and duplicate deliveries happen.
  • Set $tries, $backoff and $timeout on each job, and implement failed() to alert or clean up.
  • Use ShouldBeUnique or WithoutOverlapping for jobs that must not run in parallel for the same record.
  • Dispatch after the database commit (afterCommit) so the job never runs before its data exists.

Run workers with Supervisor

[program:laravel-worker]
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --backoff=10 --max-time=3600
numprocs=4
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopwaitsecs=3600
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log

--max-time recycles workers so memory leaks cannot build up, and stopwaitsecs should be longer than your longest job so a deploy does not kill work in progress.

Queue workers in Docker

Run workers as a separate container (or service) from the web container, using the same image with a different command, for example php artisan queue:work --max-time=3600. Scale the number of worker containers instead of using numprocs, and give the container a stop grace period longer than your longest job. If you use Redis, run Horizon as the container's command to get a dashboard, metrics and automatic balancing between queues.

Restart workers on every deploy

Workers keep the old code in memory. Run php artisan queue:restart (or horizon:terminate) as part of every deployment.

Building With Laravel?

Tell me about your Laravel project, new build, upgrade or rescue, and I'll reply within 24 hours.

Discuss Your Project

8. Caching and Performance

  • Cache expensive, rarely changing data with Cache::remember() and a clear key and expiry; clear it when the data changes.
  • Use Redis for cache, sessions and queues in production, not the file driver.
  • Run php artisan optimize on deploy to cache config, routes, events and views.
  • Paginate every list; use cursorPaginate() for very large or infinite-scroll lists.
  • Build front-end assets with Vite, and serve images in modern formats with correct sizes.
  • Measure before optimising: Telescope, Debugbar, slow query logs or an APM tool. For very high-traffic apps, consider Laravel Octane.

More techniques in web performance optimization and building a scalable web application.

9. Testing

You do not need 100% coverage. You need tests on the things that cost money or trust when they break:

  • Sign-up, login and password reset
  • Payments, invoices and anything touching money
  • Authorization: a test proving user A cannot see or change user B's data
  • Core business rules (pricing, permissions, status changes)
  • Integrations, using Http::fake(), Queue::fake(), Mail::fake() and Event::fake()
it("prevents users from viewing another team's invoices", function () {
    $invoice = Invoice::factory()->create();          // belongs to another team
    $user = User::factory()->create();

    $this->actingAs($user)
        ->get(route('invoices.show', $invoice))
        ->assertNotFound();
});

Use Pest or PHPUnit with RefreshDatabase and factories, and run the suite on every pull request.

10. Code Quality

  • Laravel Pint for consistent formatting, so code reviews discuss logic, not spaces.
  • Larastan (PHPStan for Laravel) to catch type errors and undefined methods before runtime; raise the level over time.
  • Type hints and return types everywhere, plus enums for fixed sets of values such as statuses.
  • Rector to automate upgrades between PHP and Laravel versions.
  • Clear names over comments: $overdueInvoices beats $data with a comment.

11. Deployment

  1. Run tests, Pint and Larastan in CI.
  2. Deploy atomically (Envoyer, Deployer, Forge, a container image), so users never see a half-updated app.
  3. composer install --no-dev --optimize-autoloader
  4. php artisan migrate --force
  5. php artisan optimize
  6. php artisan queue:restart
  7. Make sure the scheduler runs every minute (php artisan schedule:run from cron, or schedule:work in a container).
  8. Monitor errors (Sentry, Flare, Bugsnag) and uptime, and keep tested, off-site database backups.

12. Keep Laravel Up to Date

Laravel releases a major version roughly once a year, and each version receives security fixes for a limited period. Upgrading one version at a time, soon after release, is quick; jumping four versions at once is a project. Laravel Shift and Rector make routine upgrades cheaper. If you are stuck on an old version, see my Laravel upgrade service.

Common Mistakes I See in Laravel Projects

  • Business logic spread across controllers, models, Blade views and helpers
  • Model::all() on tables that have grown to millions of rows
  • N+1 queries on every list page
  • Lookups not scoped to the current user, exposing other customers' data
  • env() calls in application code that break after config caching
  • Slow work done during the request instead of in a queue
  • No tests on payments or permissions
  • Running years behind on Laravel and PHP versions

Applying This to Your Project

For a small company website, some of this is more than you need: conventions, validation, escaping, config caching and backups cover most of the risk. For SaaS products, marketplaces and internal systems, all of it applies; for multi-tenant products specifically, see multi-tenant SaaS in Laravel and building a SaaS with Laravel.

Need Help With a Laravel Project?

I build and maintain Laravel applications for companies in the US, UK, Canada, Australia and Europe, and I review and fix existing Laravel codebases that have become slow, fragile or hard to change. See Laravel development and legacy PHP rescue, read how to hire a Laravel developer, or tell me about your project.

Frequently Asked Questions

Should I use the repository pattern in Laravel?
Usually not as a blanket rule. Eloquent already abstracts the database, and wrapping every model in a repository interface adds code without adding value. Use query scopes and dedicated query or action classes for complex reads and writes. A repository makes sense when you genuinely need to swap the data source, for example an external API behind the same interface.
Is it safe to use raw queries in Laravel?
Yes, as long as user input is always passed as bindings, never concatenated into the SQL string. DB::select('... where id = ?', [$id]) and whereRaw('price > ?', [$min]) are safe; building the string with user input is a SQL injection risk. Raw SQL is a good tool for reporting and complex aggregates where Eloquent would be slow or unclear.
Why should I not call env() outside config files?
When you run php artisan config:cache in production, Laravel stops loading the .env file, so env() calls in your application code return null. Read environment values only inside config files and use config() everywhere else.
How should I run Laravel queue workers in production?
Run php artisan queue:work under a process manager such as Supervisor or systemd, or as its own container in Docker, with --tries, --backoff and --max-time set. Restart workers on every deploy with php artisan queue:restart so they load the new code. If you use Redis, Laravel Horizon adds a dashboard, metrics and auto-balancing.
How do I find N+1 query problems in Laravel?
Call Model::preventLazyLoading(! app()->isProduction()) in a service provider so lazy loading throws an exception in development, and use Laravel Debugbar or Telescope to see the queries each page runs. Fix them with eager loading: with(), load(), withCount() and similar.
Do these best practices apply to Laravel 12?
Yes. Everything here applies to Laravel 11, 12 and newer releases, including the slimmer application structure introduced in Laravel 11, where middleware, exceptions and routing are configured in bootstrap/app.php.
Syed Hamid — Senior Full Stack Developer

Syed Hamid

Senior Full Stack Developer & Enterprise Web Specialist

View Full Profile

Syed Hamid is a Senior Full Stack Developer based in Karachi, Pakistan, with 10+ years of experience building enterprise ecommerce platforms and SaaS applications. He has worked with clients in the US, UK, Canada, and Middle East, delivering HIPAA/GDPR compliant solutions using Laravel, PHP, Magento, and modern JavaScript frameworks. He currently maintains platforms serving millions of users.

Previous: Custom Website Development: Process, Cost & Who to Hire Next: WordPress vs Custom Development vs Wix: 2026 Comparison

Building With Laravel?

Tell me what you need. I'll get back to you within 24 hours, working in your business hours.

Start the Conversation All Services
Chat on WhatsApp