- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open your last deploy log. Find the composer install line. Look at the duration.
For most Laravel and Symfony teams, it's somewhere between 90 seconds and 4 minutes. On a deploy pipeline that should take 5 minutes end-to-end, that single step eats half the budget. The painful part: it's almost entirely fixable with three flags and one cache directive that nobody puts together in one place.
A team I worked with last month had a Laravel 12 app with about 80 packages in composer.json. Their GitHub Actions deploy was running composer install in 2 minutes 24 seconds. After the changes below, the same step takes 38 seconds. That's a 73% cut. Same packages, same lockfile, same PHP version.
Here's what's actually happening, and how to fix it.
Where Composer time goes
Three things eat the clock when Composer runs:
-
Dependency resolution. Composer reads
composer.json, walks every required package, every version constraint, every conflict. With a lockfile this is mostly skipped:composer installjust readscomposer.lockand trusts it. Without one (or withcomposer update), the SAT solver runs and it can chew CPU for tens of seconds on a large graph. -
Repository network calls. Even with a lockfile, Composer fetches package metadata from Packagist (or your private repo) to know what to download. Then it pulls each
.zipor git archive. -
Autoloader generation. After install, Composer walks every PHP file in
vendor/and writes the classmap. On a vendor tree with 8,000 files this is not free.
You don't get to skip these. You optimize each one.
Trick 1: the Composer 2.7+ parallel downloader (and how to check it's on)
Composer 2.0 shipped a parallel downloader in 2020 that broke the world for anyone still on 1.x. By 2.7 (released early 2024) it became more aggressive: it tries to download up to 12 packages in parallel by default, uses curl_multi_exec under the hood, and reuses HTTP connections via keep-alive. On a normal install with 80 packages, this turns what used to be 80 sequential HTTPS round-trips into roughly 7 batches.
Check your version first:
composer --version
# Composer version 2.7.7 2024-05-04 ...
If you see anything below 2.7, upgrade. On a CI runner that's one line:
composer self-update --2
The parallel downloader is on by default. The only reason it'd be off is if someone set COMPOSER_DISABLE_NETWORK=1 or passed --no-parallel (which they shouldn't). You can verify it's running by watching the install output: you'll see lines like Downloading (X/Y) updating concurrently rather than one after another.
One gotcha: if your private package repository sits behind a corporate proxy that doesn't support HTTP/2 multiplexing, the parallel downloader can actually be slower than serial. The fix is to set process-timeout higher in composer.json and check whether your proxy supports keep-alive. Most do. Some legacy Nexus and Artifactory setups don't.
Trick 2: the actual production install command
The default composer install is tuned for development. For production deploys, use this:
composer install \
--prefer-dist \
--no-dev \
--classmap-authoritative \
--apcu-autoloader \
--no-progress
Each flag earns its place.
--prefer-dist pulls the package as a .zip from Packagist's CDN instead of cloning the git repo. Zips are smaller and faster on cold caches. On CI runners that never have a warm ~/.composer/cache, this alone shaves 20-40% off network time.
--no-dev skips require-dev packages. PHPUnit, PHPStan, Pint, Mockery, Faker, IDE helpers: none of these belong on a production server. If your require-dev block is healthy this can cut your vendor tree in half. The Laravel 12 example above drops from 142 installed packages to 80.
--classmap-authoritative is the one most teams miss. By default Composer writes an autoloader that, when it can't find a class in its classmap, falls back to PSR-4 directory scanning. That fallback is slow. Every cache miss hits the filesystem. With --classmap-authoritative, the classmap is treated as the complete source of truth. Class not in the map? class_exists() returns false immediately. No filesystem walk. The tradeoff: any class generated at runtime (some service container proxies, some test doubles) won't be found. In production that's fine. In dev, leave it off.
--apcu-autoloader wraps the classmap lookup in APCu cache. If APCu is installed and enabled (apc.enabled=1 in php.ini, plus apc.enable_cli=1 if you call CLI commands), every class lookup after the first one is a memory hit instead of even reading the classmap array off disk. On a Symfony 7 app with 12,000 classes this matters. On a small Laravel app it matters less but still helps.
--no-progress kills the spinner. On CI runners the spinner spam bloats logs and on some terminals it actually causes Composer to flush output buffers more often than needed.
What you get from these together: a vendor/ that's about half the size, an autoloader that does zero filesystem fallback, and class resolution that hits APCu memory instead of disk.
Side note on --optimize-autoloader vs --classmap-authoritative. --optimize-autoloader (or -o) just generates the classmap upfront. --classmap-authoritative (or -a) generates the classmap AND tells Composer to never fall back. They're not interchangeable. Use -a in prod. -a implies -o.
Trick 3: cache vendor/ between CI runs
This is the lever most teams aren't pulling. GitHub Actions, GitLab CI, CircleCI: all of them support caching arbitrary directories between runs. Most PHP CI configs cache ~/.composer/cache (the download cache). Fewer cache vendor/ itself. They should.
The trick is the cache key. You want a cache hit when composer.lock hasn't changed, and a fresh install when it has. Here's the GitHub Actions block:
- name: Cache vendor
uses: actions/cache@v4
with:
path: vendor
key: vendor-${{ runner.os }}-php${{ env.PHP_VERSION }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
vendor-${{ runner.os }}-php${{ env.PHP_VERSION }}-
- name: Install dependencies
run: |
composer install \
--prefer-dist \
--no-dev \
--classmap-authoritative \
--apcu-autoloader \
--no-progress
The hashFiles('**/composer.lock') segment is what makes this safe. Any change to the lockfile invalidates the cache. The restore-keys block lets you fall back to a partial-match cache (same OS, same PHP version, different lock) so even when the lock changes, you skip re-downloading the 70% of packages that didn't actually change versions.
There's a subtle thing here that catches people. The actions/cache step runs BEFORE composer install. If the cache hits, vendor/ is already populated when Composer starts. Composer is smart enough to see the existing install, verify it against the lockfile, and skip downloading anything that matches. The whole install becomes a verification pass, usually under 10 seconds.
Don't cache by runner.os alone. Don't cache without the PHP version in the key. PHP-7-built classmaps inside vendor/ will not boot on PHP 8.4. If you ever upgrade PHP, you want the old cache to evict.
A real before/after
Same Laravel 12 app, same 80 packages, same GitHub Actions runner (ubuntu-latest, 4 cores, 16GB RAM):
Before, vanilla composer install, no cache:
- Resolving dependencies ~3s (lockfile present)
- Downloading 142 packages 78s (sequential, no cache)
- Generating autoload 63s (full classmap scan, no -a)
- Total 2m 24s
After, flags + cache hit on composer.lock:
- Verifying installed deps 6s
- Generating autoload (-a) 32s (classmap only, no PSR-4 fallback)
- Total 38s
After, flags + cache miss (lock changed):
- Resolving dependencies 3s
- Downloading 80 packages 18s (parallel, --no-dev cut 62 packages)
- Generating autoload (-a) 17s
- Total 38s
73% reduction either way. The big wins: dropping dev packages (62 fewer downloads), parallel downloader, and --classmap-authoritative doing the autoloader in half the time.
Composer plugins that quietly slow you down
Run this command in your project root:
composer show --installed | grep -i plugin
You'll likely find some combination of:
-
composer/installers: fine, lightweight. -
dealerdirect/phpcodesniffer-composer-installer: runs every install, wires up PHPCS standards. -
phpstan/extension-installer: runs every install, registers PHPStan extensions. -
bamarni/composer-bin-plugin: runs nested Composer installs fortools/. -
wikimedia/composer-merge-plugin: recursively merges sub-composer.jsonfiles. The expensive one.
Most of these have a legitimate reason to exist in dev. None of them belong in your production deploy. The --no-dev flag drops most of them, but check: some teams put plugins in require instead of require-dev by accident. Move them.
To find slow ones, run with the profile flag:
composer install --profile
You'll see [ConsoleIO] Running plugin <name> lines with timings. Anything over 2 seconds is suspect. The Magento and Drupal ecosystems have a particular history of plugin chains that add 30+ seconds to every install. If you're not in those ecosystems but somehow have those plugins, you've got history to clean up.
There's also composer suggest. Run it. Composer will tell you about "suggested" packages from your dependencies, packages your libraries hint you might want. Some of those suggestions trigger their own install-time hooks if you add them. A package I won't name suggests three monitoring plugins that each add 8-10 seconds to install. Don't add suggested packages without reading what they do.
When composer install belongs in the Docker image
If you build a Docker image as part of your deploy, composer install should run inside the image build, not on the deploy target. The image is then immutable and identical across staging and prod. Builds get cached at the Docker layer level: change a .php file, Composer doesn't re-run.
If you deploy by SSH-ing to a server and running git pull && composer install (Capistrano-style, or rolling your own), the cache layer is on the deploy target's disk. That's fine but harder to invalidate.
The wrong pattern: running composer install on every container start. I've seen this in Kubernetes setups where someone wrote an entrypoint script that does composer install before php-fpm. Every pod restart eats 90 seconds. Multiply by 12 pods scaling up during a traffic spike. The fix is to bake vendor/ into the image at build time and let pod boot be 2 seconds, not 90.
The classic mistake the other way: a multi-stage Dockerfile where composer install runs in the build stage, but then the final stage COPYs only app/ and forgets vendor/. The autoload screams at runtime. Always copy vendor/ explicitly.
If this was useful
Composer is one of those tools that does enough right that most teams never think about its config beyond require and update. The architectural lesson is similar. Most PHP teams reach for framework defaults and stop there. Once the codebase outgrows those defaults, you need a layer above the framework. Decoupled PHP is about exactly that: clean and hexagonal architecture for PHP applications that have to outlive the framework they were born in.
What's your current composer install time in CI, and which of these flags are you not using yet?
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)