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.
cargo test --no-default-features runs the tests for the lean reader
modules alone (see below).
The installer, dumper and the composr binary sit behind the cli
feature, which is on by default. If you only need to read a project's
Composer metadata, turn default features off:
[dependencies]
composr = { version = "1.1", default-features = false }That leaves three modules, which depend only on serde, serde_json,
anyhow and tracing (no tree-sitter, tokio, reqwest, zip or clap):
composr::config:ResolvedConfig::load(root, include_dev)merges the rootcomposer.jsonwith every installed package's autoload section (PSR-4, PSR-0, classmap, files), with paths made absolute.composr::lock:Lock::read/Lock::parseforcomposer.lock.composr::installed:InstalledJson::readforvendor/composer/installed.json, plus theinstalled.json/installed.phpwriters.
let cfg = composr::config::ResolvedConfig::load(project_root, true)?;
for pkg in &cfg.packages {
for (prefix, dirs) in &pkg.psr_4 {
println!("{} {prefix} -> {dirs:?}", pkg.name);
}
}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.