A Rust replacement for the slow parts of Composer-based PHP installs (vowels
not included). Targets the cold-start install path — the bit where composer install chews through dist downloads, autoload bootstrap, and Laravel's
package:discover for half a minute or more.
Working end-to-end on real apps. Two production-shape Laravel codebases
(service-app and monolith-app) install cleanly with composr in
place of composer install, with output (vendor tree, autoload bootstrap,
packages.php) that's byte-equivalent to composer's own.
Latest measured on monolith-app (292 packages):
| composer | composr | |
|---|---|---|
| Cold-cold (no caches at all) | ~72s | ~33s |
| Warm dist + classmap cache, wiped vendor | — | ~16s |
| Warm everything (touch one app file) | — | ~600ms |
package:discover step |
~30s | ~5ms |
| Composer subprocess calls | — | 0 |
Zero composer subprocess calls means the post-autoload-dump event is being fully handled natively (see "Hybrid mode" below).
Per-stage breakdown of a single install run (set COMPOSR_TIMING=1 to
print these to stderr yourself). Two snapshots:
# Cold-cold: no vendor, no classmap cache, dist cache warm
STAGE download: 2ms
STAGE extract (289 zips): ~14s # zip extraction, parallel via rayon
STAGE metadata: 9ms
STAGE bootstrap: ~20s # walk + parse + write per-package cache
STAGE scripts: 30ms
# After the first run: vendor wiped, classmap cache populated
STAGE download: 5ms
STAGE extract (289 zips): ~14s
STAGE metadata: 9ms
STAGE bootstrap: ~2.3s # 292 cache hits, no walk, no parse
STAGE scripts: 35ms
# Warm everything (re-run, nothing or one app file changed)
STAGE bootstrap: ~600ms # only the root package walked
Two layers of classmap caching make the difference:
- Per-package shared cache at
$XDG_CACHE_HOME/composr/classmap/<package>/<sha1(reference)>.bin, keyed by(package_name, dist.reference). Survivesrm -rf vendor. A hit means we skip walk + parse + admission for that package entirely and merge cached classmap entries straight into the global map. - Per-file mtime cache at
vendor/composer/.classmap-cache.binfor the root package and any uncacheable packages (path-installs, no reference). Same(mtime_ns, size)fingerprint as before.
Together they make the "I changed one app file, run install" loop a sub-second operation, while still being byte-identical to composer's classmap output on cache hits and misses.
Remaining wall-clock on cold-cold is dominated by zip extraction (~14s, parallel via rayon — bound by disk metadata throughput / largest single archive on M-series Macs). Classmap walking is no longer on the critical path after the first run.
composr install # lock-driven install: download → extract → autoload bootstrap → scripts
composr dump-autoload # rewrite vendor/composer/autoload_classmap.php (and patch autoload_static.php)
composr discover # rewrite bootstrap/cache/packages.php (Laravel's package:discover)
composr install-hooks # write post-checkout/-merge/-rewrite git hooks (see "Git hooks")
composr # alias for `dump-autoload` (back-compat with the original tool name)
composr install is the one-stop shop. The others are mostly useful for
debugging or for users who only want one piece.
- Reads
composer.lock; checks the content-hash againstcomposer.json(advisory). - Reads
vendor/composer/installed.json, computes diff: install / remove ops. - Concurrent HTTP fetch of every dist archive we don't already have
cached. Cache lives at
$XDG_CACHE_HOME/composr/files(override withCOMPOSR_CACHE_DIRor--cache-dir). - Removes (rm -rf old install paths + their bin proxies).
- Extracts each zip to its install path; sets up vendor/bin proxies.
dist.type == "path"packages get symlinked from sibling repos. - Writes
vendor/composer/installed.json,installed.php, andInstalledVersions.php(the latter bundled MIT from composer/composer). - Generates the autoload bootstrap natively when missing
(
autoload.php,ClassLoader.php,autoload_real.php,autoload_static.php, the four data files,platform_check.php,LICENSE). When the bootstrap is already in place, takes the fast path and just patches the classmap. - Runs script lifecycle events (
pre-install-cmd,post-autoload-dump,post-install-cmd) — see "Hybrid mode" for how class-method handlers are dispatched.
Native port of Illuminate\Foundation\PackageManifest::build. Reads
vendor/composer/installed.json and the root composer.json's
extra.laravel.dont-discover, accumulates transitive opt-outs, drops
empty configs, and writes bootstrap/cache/packages.php byte-compatible
with PHP's var_export. Used internally by composr install's
post-autoload-dump path; also runnable standalone.
composr discover --root <project> [--vendor-dir <p>] [--manifest-path <p>]
--vendor-dir is for layouts where the Laravel base path lives inside a
larger composer project (e.g. monolith-app's laravel-app/ with a
sibling vendor/).
Composer's EventDispatcher is the only correct way to invoke
class-method script handlers (Foo\Bar::baz-shaped entries in
composer.json scripts). composr does not reimplement it. Instead, when
a script event contains a class-method handler, composr shells out to
a real composer run-script <event> for that one event.
Two narrowing exceptions reduce the delegation surface for Laravel apps:
Illuminate\Foundation\ComposerScripts::postAutoloadDumpis recognized as the known clearCompiled handler and skipped natively. Its only effect is unlinking cachedservices.phpandpackages.php— both are regenerated immediately afterward (by the typicalrm -f bootstrap/cache/*.phpline that follows it, and by composr's native discover).@php <artisan> package:discover [args]is replaced with nativecomposr discover(basePath derived from the artisan path, vendor dir = the install pipeline's<root>/vendor).
If both exceptions fire and every other entry in the event is a plain
shell/php script, the entire post-autoload-dump runs without a
composer subprocess. That's how monolith-app gets to "0 composer calls"
on a cold install.
Composer binary resolution order: --composer-bin <path>,
COMPOSR_COMPOSER env, composer on PATH. If nothing's found and
delegation is needed, composr warns and continues with class-method
handlers skipped.
Composer-plugin packages get installed as plain library files; whether their composer-side hooks run depends on the plugin:
-
Natively replicated — composr ports the plugin's install-time codegen to Rust and writes the same artifact composer would. Output is byte-equal to composer's plugin output (verified by golden tests against real composer in the integration suite). Currently:
pestphp/pest-plugin— writesvendor/pest-plugins.json. Without it pest's runtimeLoaderreturns[]and every Pest plugin (Coverage, Bail, Cache, Retry, Snapshot, Parallel,pest-plugin-arch, …) silently no-ops. Gated on the project'sconfig.allow-plugins.pestphp/pest-plugin: true.tbachert/spi— writesvendor/composer/GeneratedServiceProviderData.php(and splices the class intoautoload_classmap.php/autoload_static.php). Replicates theextra.spidata flow; recognizes#[Nevay\SPI\ServiceProviderDependency\PackageDependency](statically evaluated against the lock) and#[…\ExtensionDependency](emitted as the runtime conditional wrap). Gated onconfig.allow-plugins.tbachert/spi: true. Limitations: doesn't replicateextra.spi-config.autoload-files(which would require running PHP at install time); presence of any such package logs a warning and falls through to delegation.
-
Inert — installing the files alone is sufficient; the composer-side activation has no observable install-time effect for the apps we run. Currently
php-http/discovery,pestphp/pest-plugin-{laravel,mock,arch,type-coverage}, andphpstan/extension-installer. -
Unknown — anything else. composr forces a
composer run-script <event>delegation per lifecycle event so the plugin's subscribers still fire (assuming acomposerbinary is reachable; without one, composr warns that those hooks won't run). Project-local exceptions go incomposr.json→allow-inert-plugins(supports*wildcards matching composer'sBasePackage::packageNameToRegexp):{ "allow-inert-plugins": ["vendor/some-plugin", "vendor/*", "*/util-*"] }--strict-pluginsignores all allowlists and refuses any composer-plugin in the lock.
composr install:
--no-dev— matchcomposer install --no-dev--no-scripts— skip every script lifecycle event--no-discover— opt out of thepackage:discoverinterception--no-composer— never shell out, even for class-method handlers (those will be skipped with a warning)--strict-plugins— abort if anycomposer-pluginis in the lock (default behavior is to install plugins as inert files and warn)--concurrency <n>— parallel HTTP downloads (default ~CPU count, capped at 12)--dry-run— print the install/remove operations and exit
cargo test
121 unit tests + integration tests at the time of writing, covering:
PHP parsing edge cases (non-UTF-8 class names, anonymous classes,
nested namespaces), classmap emission, autoload bootstrap files, lock
parsing, install diff, dist extraction (zip + path-symlinks), script
entry classification, the discover algorithm + var_export output
shape, PHP-compatible json_encode (default + JSON_PRETTY_PRINT,
verified against PHP 8.5), composr.json glob-pattern matching, and
golden-byte comparisons for both natively-replicated plugins
(pest-plugins.json and GeneratedServiceProviderData.php) against
real composer's output on a fixture lock.
composr git-hook <label> runs install --no-scripts with a styled
preamble line, suitable for post-checkout / post-merge /
post-rewrite. It bails silently if there's no composer.json at cwd
and always exits 0, so a sync that fails for any reason can't block the
git op. The only thing you need on $PATH is composr itself.
The fast path is to let composr write the hooks for you:
composr install-hooksThat drops post-checkout, post-merge, and post-rewrite into the
repo's hooks dir (resolved via git, so core.hooksPath, worktrees, and
submodules all work) and marks them executable. Re-running is safe — it
overwrites only hooks composr wrote and skips any it doesn't recognize
(pass --force to overwrite those too).
If you'd rather wire them up by hand, drop these into .git/hooks/
(mark chmod +x) — they're exactly what install-hooks writes:
# .git/hooks/post-checkout
#!/usr/bin/env bash
[ "$3" = "1" ] || exit 0
command -v composr >/dev/null 2>&1 || exit 0
composr git-hook post-checkout# .git/hooks/post-merge
#!/usr/bin/env bash
command -v composr >/dev/null 2>&1 || exit 0
composr git-hook post-merge# .git/hooks/post-rewrite
#!/usr/bin/env bash
cat >/dev/null # drain stdin so we don't SIGPIPE the parent
command -v composr >/dev/null 2>&1 || exit 0
composr git-hook post-rewriteBranch switches, pulls, and rebases will reconcile vendor + classmap
automatically. composr install is idempotent and ~700ms warm-cache
when there's nothing to do, so it's cheap to run on every git op.
--no-scripts is the conservative default — drop it from the
subcommand once you've audited that your project's post-install-cmd
is safe to fire on every branch switch.
$COMPOSR overrides the binary path (defaults to composr on PATH).
$NO_COLOR is honored.
One extra shell helper lives in scripts/ for a case the binary
doesn't cover. Drop it on $PATH (or symlink it into ~/bin) to use
it. (It's in the git repo only — not shipped in the published crate.)
composer-dump-fast— drop-in forcomposer dump-autoload -o: runs the native classmap dumper, then asks composer to fire pre/post-autoload-dump scripts (so Laravel'spackage:discoverand friends still run). Useful in CI flows that compose composr with a partial composer dispatch.
- No
composer update. composr is lock-driven; you still need composer to refresh the lock againstcomposer.json. - Composer-plugin coverage. See "Plugin policy" above for the full
story — pest-plugin and tbachert/spi are natively replicated; a small
allowlist is treated as inert; everything else gets delegated to
composer run-scriptper event. - Platform-check stub.
vendor/composer/platform_check.phpis emitted as a no-op. A correct implementation would emit php-version- ext-* checks from the lock's platform reqs.
route:cache,view:cache,optimizeetc. — Laravel's other cacheable artisan commands aren't intercepted yet. Same shape aspackage:discover; doable in a follow-up.- Plugin-driven service-provider discovery (rare, e.g. some packages
register providers via composer plugin code rather than
extra.laravel). composr matches Laravel here: not handled. Those projects rely on plugin activation, which runs only viacomposer install— install via composer once, then composr from there.