Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
laravelphparchitectureperformance

Laravel Best Practices for Production PHP Apps

A practical Laravel best practices guide covering service container bindings, Eloquent query discipline, queued events, cache design, and testing seams for

Oct 1, 2026 · 7 min read

Most Laravel apps that hurt in production were not built badly. They were built in the order the framework encourages: routes first, controllers next, Eloquent calls sprinkled through, and cross-cutting work added when a deadline forces it. The result is a codebase that passes feature tests and still collapses under real traffic because every request pays for decisions that were made for convenience, not for load.

This guide is the set of Laravel best practices I apply when a project moves from "works on my machine" to "runs on a fleet." Each one is cheap to adopt on a fresh project and expensive to retrofit later. I will explain what breaks when you skip it, and how to verify the fix with a query log or a profiler rather than a hunch.

What breaks first when you skip these practices

Three failure modes account for most Laravel production incidents I have seen: unbounded Eloquent queries that scale with table size, synchronous side effects that block the request path, and shared mutable state that leaks across requests when you run under Octane. Fixing each is a discipline, not a package. The sections below turn each failure mode into a concrete check you can run against your own code.

Key takeaway

Adopt these practices in this order: container bindings for testability, query discipline for scale, queued events for latency, cache design for load, and feature tests for regressions. Each layer only pays off if the one beneath it is sound.

Bind in the container before you need it

The service container is not a dependency injection ceremony. It is the seam that lets you swap an expensive or external dependency for a test double without touching call sites. Bind interfaces to implementations in a service provider, inject the interface into controllers and jobs, and keep constructors free of business logic. When a class resolves to a concrete class with no binding, you have lost the ability to substitute it later without editing every consumer.

// app/Providers/AppServiceProvider.php
use App\Contracts\PaymentGateway;
use App\Services\StripeGateway;
use App\Services\FakeGateway;

public function register(): void
{
    $this->app->bind(PaymentGateway::class, function ($app) {
        return $app['config']->get('services.payments.driver') === 'fake'
            ? new FakeGateway()
            : new StripeGateway($app['config']->get('services.stripe.key'));
    });
}

// Anywhere downstream: constructor injection, no new StripeGateway() calls.
public function __construct(private PaymentGateway $gateway) {}

Verify the binding is actually used: run php artisan tinker and call app(PaymentGateway::class) twice, then assert both instances are the same class. If you get a different concrete class than expected, the binding is not registered or is being overridden by a later provider.

Keep Eloquent queries bounded

An N+1 query is not a style problem. It is a linear cost multiplied by row count, and it appears in production as a slow endpoint that passes every test because your fixtures had three rows. The fix is eager loading, but the deeper practice is refusing to let a query shape depend on data you have not loaded. Load what you need up front, constrain it, and never call a relationship method inside a loop without having eager loaded it.

// Slow: one query for orders, then one per order for its customer.
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->customer->email;
}

// Bounded: two queries total, regardless of order count.
$orders = Order::with('customer')->latest('placed_at')->limit(200)->get();

Turn on the query log in a feature test and count the queries, rather than eyeballing the output:

DB::enableQueryLog();
$response = $this->getJson('/api/orders');
$response->assertOk();
$this->assertLessThanOrEqual(5, count(DB::getQueryLog()));

If the count grows with fixture size, you have an N+1 regardless of what the profiler screenshot looks like. For a longer treatment of this failure mode, see Fix Eloquent N+1 in Laravel.

Move side effects off the request path

Anything that calls an external service, sends email, writes to a slow datastore, or does more than a few hundred milliseconds of work belongs in a queued job. The request should record what happened and return; the worker should do the work. This is the single highest-leverage change for tail latency, and it also gives you retries, backoff, and a failure table for free.

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SendOrderReceipt implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order) {}

    public function handle(ReceiptRenderer $renderer): void
    {
        Mail::to($this->order->customer->email)
            ->send(new ReceiptMail($renderer->render($this->order)));
    }
}

// In the controller: dispatch, do not await.
SendOrderReceipt::dispatch($order);

The trade-off is consistency: the user sees a success response before the email exists. If that matters, use afterCommit on the job so it only dispatches once the database transaction commits, and design the UI to reflect "queued" rather than "done." For retry strategy and backoff tuning, read Laravel Queue Best Practices.

Design the cache around invalidation, not hits

A cache hit rate is a vanity metric if you cannot say when a value is stale. Before you wrap a query in Cache::remember, write down the invalidation trigger: which model event, which tag, which version bump. If you cannot name one, you do not have a cache, you have a delay before a bug.

$product = Cache::remember(
    "product:{$id}:v{$cacheVersion}",
    now()->addMinutes(15),
    fn () => Product::with('variants')->findOrFail($id)->toArray()
);

Versioned keys are the cheapest invalidation strategy: bump the version on deploy or on a schema change and every stale key expires naturally without a tag sweep. Cache tags work in Redis and Memcached but not in the file or database store, so confirm your driver before you depend on them. The trade-off table and stampede protection patterns are covered in Laravel Caching Strategies.

PracticeFailure it preventsVerificationCost to adopt
Container interface bindingsUntestable concrete couplingtinker resolves the interface to the expected classLow, one provider edit
Eager loading + query capsN+1 and unbounded result setsQuery log count in a feature testLow, per endpoint
Queued jobs for side effectsBlocked request latencyQueue dashboard shows jobs drainingMedium, requires a worker
Versioned cache keysSilent stale readsDeploy bump invalidates old keysLow, one constant
Feature tests over unit mocksRegressions in request flowTest suite fails on a seeded bugMedium, ongoing

Test the seams you built

The point of container bindings and queued jobs is that they create seams. Feature tests exercise the request path end to end with a fake gateway and a synchronous queue, so a regression in routing, validation, or authorization fails the suite instead of production. Unit tests that mock every collaborator prove only that your mocks agree with your assumptions.

// tests/Feature/OrderCheckoutTest.php
public function test_checkout_dispatches_receipt_job(): void
{
    Queue::fake();

    $this->postJson('/api/checkout', [
        'product_id' => 1,
        'quantity' => 2,
    ])->assertCreated();

    Queue::assertPushed(SendOrderReceipt::class, 1);
}

Set the queue sync driver in phpunit.xml for tests that need the job to actually run, and fake it when you only care that it was dispatched. Mixing the two is the most common source of "the test passes but the job never ran" confusion.

Frequently asked questions

Should I bind every class in the service container?

No. Bind interfaces, third-party clients, and anything with per-environment configuration. Concrete classes with no substitution need are resolved automatically and adding bindings for them adds indirection without payoff. Start with the dependencies your tests need to fake.

Is eager loading always faster than lazy loading?

For relationship access in a loop, yes, because it replaces N queries with one. For a single record where you may not touch the relationship at all, eager loading adds a query you did not need. Measure with the query log on your real data shape before standardizing on either.

How do I choose between sync and queued listeners?

Queue anything that touches the network, a slow datastore, or takes more than roughly 100 milliseconds. Keep sync only for work that must be visible in the same response, such as updating an in-memory cache the response reads. The trade-offs are covered in Laravel Events and Listeners: Sync vs Queued.

What is the fastest way to audit an existing Laravel app?

Enable the query log on your busiest endpoint and count queries per request. Then grep for ::all(), ::get() without limit, and relationship access inside loops. Those three searches surface most scaling problems in minutes, before you touch architecture.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Oct 3, 2026 · 6 min read

    JavaScript Event Loop: Blocking vs Non-Blocking

    How the JavaScript event loop turns single-threaded code into non-blocking I/O, where microtasks and macrotasks differ, and the exact patterns that still

  • Oct 7, 2026 · 8 min read

    PHP Type Juggling: Pitfalls and Fixes

    How PHP's loose comparison rules cause real bugs, which behaviors changed in PHP 8, and when to use strict types and strict comparisons to avoid them.

On this page

  • What breaks first when you skip these practices
  • Bind in the container before you need it
  • Keep Eloquent queries bounded
  • Move side effects off the request path
  • Design the cache around invalidation, not hits
  • Test the seams you built
  • Frequently asked questions
  • Should I bind every class in the service container?
  • Is eager loading always faster than lazy loading?
  • How do I choose between sync and queued listeners?
  • What is the fastest way to audit an existing Laravel app?
Back to top

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick

  • Privacy
  • Terms
  • Cookies