DEV Community

Cover image for The Laravel Service Container Trick That Cuts Boot Time by 40%
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Laravel Service Container Trick That Cuts Boot Time by 40%


You push a tiny Laravel 12 app to production. It does almost nothing. Render a JSON endpoint, run a migration, dispatch a queue job. And yet php artisan route:list takes 380ms before it prints a single line. A team I talked to last month had a CI pipeline where artisan migrate alone ate 2.4 seconds per run, multiplied by 40 microservices. That's a minute and a half of CI time, every deploy, doing nothing but booting Laravel.

The container is the culprit. Not your code. The container.

The good news: you can fix most of it without rewriting anything. Two patterns, both shipped with Laravel since forever, and most teams use one of them wrong and the other not at all.

What "boot" actually does in Laravel

When you run php artisan or hit public/index.php, here's what happens before your code runs:

  1. Composer's autoloader loads.
  2. bootstrap/app.php builds the Application instance.
  3. The kernel registers every service provider listed in config/app.php plus everything Laravel auto-discovers from your installed packages.
  4. Each provider's register() method runs. This binds things into the container.
  5. Each provider's boot() method runs. This is where event listeners, view composers, route macros, validation rules, and 90% of "the framework is now alive" work happens.

The slow part isn't your code. It's step 3 and step 5. A clean laravel/laravel install ships with ~25 service providers. Add Sanctum, Horizon, Telescope, Nova, Scout, Cashier, Pulse, Octane, and you're at 40+. Each one runs register() and boot() on every single request unless you tell Laravel otherwise.

Package discovery makes it worse. Laravel scans composer.json for every installed package and auto-registers anything that declares extra.laravel.providers. That scan is cached in bootstrap/cache/packages.php. The execution of those providers isn't cached. They run on every boot.

The result: a "small" Laravel app spends 60–80% of its cold-boot time inside the container, before a single line of your code executes.

Trick 1: Deferred providers, used right

Laravel has a feature called DeferrableProvider that most teams ignore or use wrong. The idea: a provider doesn't need to load until something asks for one of its bindings. If nothing in the current request touches it, it never runs.

Here's a deferred provider for a StripeClient. The real one. Not a toy:

<?php

namespace App\Providers;

use App\Services\Payments\StripeClient;
use App\Services\Payments\WebhookVerifier;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient as StripeSdk;

class StripeServiceProvider extends ServiceProvider implements DeferrableProvider
{
    // the array form: fastest path, no closures, no reflection
    public array $bindings = [
        StripeClient::class => StripeClient::class,
    ];

    public function register(): void
    {
        $this->app->singleton(StripeSdk::class, function ($app) {
            return new StripeSdk($app['config']['services.stripe.secret']);
        });

        $this->app->singleton(WebhookVerifier::class, function ($app) {
            return new WebhookVerifier(
                $app['config']['services.stripe.webhook_secret']
            );
        });
    }

    // tells Laravel which container keys this provider is responsible for
    public function provides(): array
    {
        return [
            StripeClient::class,
            StripeSdk::class,
            WebhookVerifier::class,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things matter here.

First, provides() must list every binding this provider registers. Laravel uses it to build the deferred-services manifest at bootstrap/cache/services.php. If you bind Foo and Bar in register() but only return Foo from provides(), Laravel will defer the provider, then Bar will silently fail to resolve when something asks for it. You'll get a BindingResolutionException that points at a class that should obviously be there. Teams burn an hour on this regularly.

Second, the $bindings property. If your bindings are simple Concrete => Concrete or Interface => Concrete with no closure logic, declare them as a property instead of writing them in register(). Laravel reads $bindings and $singletons directly without invoking register() at all for the deferred case. That saves you the closure construction cost.

Here's the gotcha. provides() returns container keys, not class instances. If you bind a string alias like stripe.client, return 'stripe.client', not StripeClient::class. Mismatched aliases is the most common deferred-provider bug.

The default Laravel install defers almost nothing. Pagination, password reset, broadcasting: all eagerly loaded. Look at your app's providers in bootstrap/providers.php and ask: does an admin-only command really need to register on every public-facing request? Does the Stripe client need to load when someone hits /health? On a typical Laravel 12 app with Sanctum, Horizon, Telescope, and a Stripe layer, 14 of 22 providers can be deferred. Most teams ship 2 or 3.

Trick 2: Service provider order

This one's subtle. Service providers register top-to-bottom from bootstrap/providers.php (and config/app.php in older versions). The order matters more than you'd think because providers depend on each other through the container.

If LogServiceProvider runs first and EventServiceProvider runs second, and your log handler is itself an event listener, you've now got the framework registering things twice: once before the listener exists, once after. Laravel handles this gracefully, but "gracefully" means more work.

Five providers register most of the container:

  1. EventServiceProvider: every other provider's boot() method can fire events.
  2. RouteServiceProvider: defines route groups and middleware before any HTTP-bound provider runs.
  3. AuthServiceProvider: guards and policies that downstream providers want to attach to.
  4. LogServiceProvider: so every other provider can log during boot.
  5. DatabaseServiceProvider: config-bound, used by half the ecosystem.

Put these five first, in that order. Then your application providers. Then anything deferrable. The savings are small per provider (1–3ms), but they compound, and you stop re-resolving the same singleton during boot because dependents now find their dependencies already constructed.

Real numbers from php artisan optimize

The biggest single boot-time win is php artisan optimize. It writes three files to bootstrap/cache/:

  • config.php: every config/*.php file merged into a single PHP array. Laravel includes this once instead of glob()-ing the config directory on every boot.
  • routes-v7.php: your routes/web.php and routes/api.php compiled to a single matcher. Skip the route-collection build phase entirely.
  • services.php: the deferred services manifest (only meaningful if you actually use DeferrableProvider).
  • packages.php: the cached package discovery from composer.json scanning.

Here are representative numbers from a Laravel 12 app: about 90 routes, 22 service providers, Sanctum + Horizon + Telescope installed, PHP 8.4, no Octane.

# baseline: fresh install, no cache, default providers
$ for i in 1 2 3 4 5; do time php artisan route:list >/dev/null; done
0.412s 0.398s 0.421s 0.405s 0.418s    avg: 0.411s

# after `php artisan optimize`, no other changes
$ php artisan optimize
   INFO  Caching framework bootstrap, configuration, and metadata.
   config ............................. 12.4ms DONE
   events ............................. 8.1ms DONE
   routes ............................. 22.7ms DONE
   views .............................. 31.2ms DONE

$ for i in 1 2 3 4 5; do time php artisan route:list >/dev/null; done
0.298s 0.291s 0.302s 0.295s 0.301s    avg: 0.297s

# now defer 14 of 22 providers (Stripe, Telescope, Nova, Scout, etc)
$ for i in 1 2 3 4 5; do time php artisan route:list >/dev/null; done
0.244s 0.249s 0.251s 0.241s 0.247s    avg: 0.246s
Enter fullscreen mode Exit fullscreen mode

Baseline to optimised: 40% faster. From 411ms to 246ms.

The biggest jump is optimize at 28% on its own. The deferred providers add another 17% on top, because route:list doesn't touch Stripe, Telescope, or Nova, so those providers never run.

For a public HTTP request that doesn't hit those code paths either, the win is bigger. Hitting /api/health went from 89ms to 51ms on the same machine. That's not nothing when your CDN cache misses or your queue worker boots per-job.

The gotcha: bootstrap/cache/services.php and CI/CD invalidation

Here's where teams shoot themselves in the foot.

php artisan optimize writes cache files based on the code on disk right now. If you bake those files into a Docker image or a deploy artifact, they're frozen to that version. The moment you ship new code that adds a binding, changes a route, or tweaks a config value, the cache is wrong.

Symptoms of stale cache:

  • BindingResolutionException for a class that exists in the codebase and was added recently.
  • RouteNotFoundException for a route name that's in routes/web.php right now.
  • Config values that don't match .env: usually the old value, sometimes a value that doesn't exist anywhere.
  • Worst: a deferred provider's provides() array got a new entry but the manifest doesn't know it. The binding silently doesn't load.

The fix is mechanical. Your deploy pipeline must:

  1. Pull new code.
  2. Run composer install --no-dev --optimize-autoloader.
  3. Run php artisan optimize:clear to nuke everything in bootstrap/cache/.
  4. Run php artisan optimize.
  5. Swap the new code in.
  6. Reload PHP-FPM workers (or restart Octane, or whatever your runtime is).

Step 3 is the one that gets skipped. People think optimize is idempotent and overwrites everything. It isn't. optimize writes the four cache files but leaves anything else alone. If a previous deploy left bootstrap/cache/livewire-components.php or some package's cache file, the new boot still reads it. optimize:clear is the only thing that actually removes everything.

A second gotcha: Forge, Vapor, and most managed Laravel hosts run optimize automatically. Self-hosted setups don't. Check your deploy script before assuming. The number of BindingResolutionException reports in Laravel issues that resolve with "did you run artisan optimize" is high enough that the framework should probably scream louder when the cache doesn't exist.

When boot time doesn't matter

Octane and Horizon change the math.

With Octane (Swoole or RoadRunner), Laravel boots once per worker process and stays in memory. Your 246ms boot happens once at worker startup, then every request is 5–10ms of pure application code. Deferred providers don't help here. They all eventually load, because the worker handles every kind of request over its lifetime. Sometimes they hurt: a deferred provider that loads on request #500 stalls that one request for the same amount of time it would've stalled boot.

Horizon is similar. The supervisor boots Laravel once per worker, then the worker pulls jobs forever. Boot is a one-time tax.

Where boot time still bites with long-lived workers:

  • Worker restarts. Horizon restarts workers every N jobs or N seconds to avoid memory leaks. Each restart is a fresh boot.
  • Deploys. Octane reload is a worker recycle.
  • CI/CD. Every artisan migrate, artisan test, artisan db:seed in your pipeline is a cold boot.

If you're on traditional PHP-FPM, every request is a cold boot. Every. Single. Request. That's where optimize and deferred providers pay rent.

The honest take: most apps still run on PHP-FPM, and those apps are paying a 100–400ms boot tax on every request that nobody's bothering to fix because "Laravel feels fast enough." It does, until you ship 40 microservices and watch CI take 8 minutes per pipeline. Then 40% of boot time matters a lot.

The container isn't doing anything dumb. You just haven't told it which parts of your app it can skip.

What's your current boot time on a cold php artisan call, and how many of your service providers could be deferred without breaking anything?


If this was useful

Boot time is the visible edge of a bigger question: what does your framework actually do, and what does it leave for you to wire up? Once you start treating service providers as architecture instead of magic, you start asking where the framework ends and your domain begins. That question is the whole subject of Decoupled PHP — clean and hexagonal architecture for PHP apps that need to outlive any single framework's defaults.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)