Architecture

vivace (viv) is a from-scratch, byte-compatible reimplementation of Composer: it solves composer.json into a composer.lock, and installs a lock into a vendor/ directory that is a drop-in for Composer's own. It is to Composer what uv is to pip: same inputs and outputs, most of the speed comes from how packages are fetched, stored, and materialised, and from a solver built for throughput rather than PHP's object model.

Pipeline

composer.json ──► repository metadata ──► pool ──► PoolOptimizer ──► CDCL solver
                                                                          │
                                                                          ▼
                                                     transaction ──► lock_writer ──► composer.lock

composer.json + composer.lock
        │
        ▼
  lock::read ──► Vec<Package>, plugins::resolve (native adapter or refusal)
        │
        ▼
  plan: lock vs vendor/composer/installed.json + a small state file
        │   unchanged content-hash/composer.json/--no-dev, no orphans → no-op
        ▼
  fetch (tokio + reqwest) ──► bytes, sha1 checked; or source.rs for path/git
        ▼
  store: extract once into $XDG_CACHE_HOME/vivace/, read-only, `.ok`-marked
        ▼
  link: hardlink every store file into vendor/<vendor>/<name>/ (copy fallback)
        ▼
  isolate: clash check on every plugin's bundled vendor/; php-scoper prefix
           for the plugins named in extra.viv.isolate
        ▼
  autoload + bin: vendor/autoload.php, vendor/composer/*, vendor/bin/*
        ▼
  scripts: pre/post-install-cmd, pre/post-autoload-dump

viv install runs the lower half only, from an existing lock; with no composer.lock it warns and runs viv update instead, as Composer does. viv update, viv add and viv rm run the solver first, write a new lock, then chain into the lower half and install (--no-install opts out).

Modules

Module Job
lock Parse composer.lock and the root composer.json (autoload, autoload-dev, config). Keeps JSON key order (serde_json preserve_order) because installed.json re-emits lock entries.
repository Packagist v2 and v1 (Satis/Private Packagist) metadata clients, package (inline composer.json declarations), multi-repository construction from composer.json's repositories, an HTTP cache mirroring Composer's disk format.
solver Port of Composer's CDCL dependency solver (pool, pool_builder, pool_optimizer, rule_set_generator, rules, watch_graph, decisions, policy, solver, transaction, request, platform). Full updates only; viv install never reaches it.
lock_writer Writes composer.lock from a solved transaction: top-level key order, content-hash, per-package ArrayDumper shape. Its normalize_time (a package's time field, rendered the way ArrayDumper prints it — an explicit offset kept, not shifted to UTC) is shared with autoload::installed, which needs the same rendering for installed.json.
require viv add/viv rm: constraint synthesis and a format-preserving composer.json edit, then a partial update of the touched package(s).
update Wires repositories, the solver and lock_writer together for viv update/viv update --lock (viv update-lock's alias).
plan Diff the lock against vendor/composer/installed.json to decide what to keep, install, and remove.
plugins Native adapters for the Composer plugins vivace ports, one module per adapter behind a shared Adapter trait (install path, patch/scaffold, generator and dist-URL-rewrite phases, each a default no-op): composer/installers and *-wordpress-core-installer (install path mapping), dealerdirect/phpcodesniffer-composer-installer, phpstan/extension-installer, tbachert/spi (post-install generators), cweagans/composer-patches (patch application), yiisoft/yii2-composer, craftcms/plugin-installer, codeception/c3, ffraenz/private-composer-installer, drupal/core-composer-scaffold, symfony/runtime, altis/cms-installer, altis/core, altis/dev-tools-command. Plugins::resolve builds the enabled adapters once from the lock; install.rs calls one method per phase and never names an adapter module. Everything else of type composer-plugin is refused unless --no-plugins. .github/workflows/adapter-drift.yml checks each adapter's pinned upstream_version() against Packagist weekly and files an issue for any that have fallen behind. See docs/plugin-strategy.md for how to add one.
source Path-repository and dist-less git-source lock entries: symlink/mirror a path package, clone-and-checkout a git one, bypassing the store. Git mirrors take a per-URL file lock, so two viv processes sharing a cache dir don't race on one mirror's config (vcs.rs does the same for its own mirrors).
fetch Download dist archives. Zip only. Bounded concurrency, HTTP/2, follows GitHub API redirects to codeload. Verifies dist.shasum (sha1) when non-empty.
auth Composer-compatible credentials: auth.json (Composer home, then project), then COMPOSER_AUTH, ascending precedence.
store Global content-addressed store keyed by the archive's sha256, plus a dists-v0/<vendor>/<name>/<ref> symlink layer and a .classmap-v0 sidecar caching the extracted tree's classmap scan. Extraction strips a single top-level directory the way Composer does, rejects zip-slip paths, preserves exec bits, writes read-only files.
link Materialise a store tree into vendor/. Hardlink per file; falls back to copy across devices or with --link-mode copy.
autoload Generate the autoloader files (generator, php, sort, classmap, installed, platform). Embeds Composer's ClassLoader.php, InstalledVersions.php, and LICENSE verbatim from templates/.
bin vendor/bin proxies: a port of Composer's BinaryInstaller for Unix proxy scripts (PHP and sh shapes; no Windows .bat writer).
scripts Dispatches the four events install/dump-autoload care about (pre-install-cmd, pre-autoload-dump, post-autoload-dump, post-install-cmd) from the root package's own scripts section.
install Wires lock parsing, plugins, planning, fetch/store/link, autoload and scripts together for viv install, plus cache maintenance (viv cache prune/clean/size) and viv dump-autoload.
show viv show/viv tree/viv why/viv outdated: read-only inspection of installed packages.
audit viv audit: security advisories and abandoned packages from Packagist's security-advisories API.
diagnose viv diagnose: a read-only, plain-text environment/config report (cache, auth sources by host name only, PHP/git/Composer, platform packages, plugin decisions) to paste into a bug report; not byte-compatible with composer diagnose.
validate viv validate: a native ConfigValidator/ValidatingArrayLoader port for composer.json, plus lock freshness/completeness checks.
normalize viv normalize: a native ergebnis/composer-normalize for composer.json's key order and formatting.
tool viv x/viv run/viv exec: npx-style one-off tool execution, scripts::Runner entry points, and a bare vendor/bin exec. viv x resolves a tool against the current project's pinned PHP, which isolate reuses to run php-scoper.
version, semver Composer version normalisation and constraint parsing/matching, shared by the solver, show, and the autoloader's version dumps.
time Civil-date/epoch-day helpers shared by show (release-age math), lock_writer (normalising a package's time field) and vcs (VCS timestamp formatting).
native_lock Chapter 1's viv.lock writer and reader, and viv lock convert, which translates an existing composer.lock into it without re-solving.
lock_merge Record-level three-way merge and re-solve for viv lock merge, the composer.lock/viv.lock git merge driver. A dev-* record neither side can win by time keeps conflict markers, with a stderr line naming the package and both commits. --offline-rung (opt-in) tries one parent's own pinned record, checked against the two locks' own require/conflict/replace/provide/platform data with no registry fetch, once the registry escalation has already failed at every rung.
isolate viv isolate, and the clash check install/update run after linking. Prefixes a plugin's bundled vendor/ with php-scoper (run through tool's environment on the project's pinned PHP), checks the result with php -l and a load check, and caches it in the store's isolated-v0 bucket. Only plugins named in extra.viv.isolate are prefixed; the prefix map lives in vendor/composer/.vivace-state.
workspace Chapter 3's extra.viv.workspace member discovery and viv workspace list; viv workspace init/viv workspace add write and grow a plain aggregate root, one path repository and require line per matched pattern, then resolve and install through require::partial_update.
main.rs (crate root, viv) The CLI: install, update/update-lock, require, remove, dump-autoload, normalize, cache, audit, show/tree/why/outdated, validate, x, run, exec, php, isolate, diagnose, lock (convert, merge, export), workspace, completions and the hidden man (roff pages for the release assets), plus --offline and --cache-dir.

Composer and Riff cache archives and unzip them into vendor/ on every install. Unzipping is the dominant cost once downloads are cached (Riff's warm install on a 101-package Laravel lock spends most of its 230 ms in system calls from extraction). A store holds each package extracted exactly once. A warm install then does one link() per file, which on ext4 is an order of magnitude cheaper than inflating and writing it. The same store serves every project on the machine.

Hardlinks share inodes, so an edit to a vendor file would change the store. Store files are therefore read-only (0444), which makes hardlinked vendor files read-only too. Editing fails loudly rather than corrupting other projects. Projects that patch vendor use --link-mode copy, which adds the owner write bit back so the copied files can be edited directly.

The no-op path

vendor/composer/.vivace-state records the lock's content-hash, the --no-dev flag, a hash of the root composer.json and the isolate prefix map. When these match and installed.json has no orphans, viv install regenerates nothing and touches no network; the plugin clash check still runs, from its cached verdict. Target: tens of milliseconds.

Compatibility rules

Everything in vendor/composer/ follows docs/composer-contract.md. The integration tests install the fixtures in tests/fixtures/ and diff the result against expected/, produced by real Composer. Differences are bugs, not style. compat/ runs the same comparison against a corpus of real projects before a release.

Not supported

Each of these fails with a clear error, naming the reason:

  • Any composer-plugin package without a native adapter, unless config.allow-plugins is false/absent for it — --no-plugins downgrades the refusal to a warning and installs as Composer would with that flag (docs/plugin-strategy.md).
  • GitLab, Bitbucket and other non-GitHub VCS hosts in update: they fall back to the generic git driver, which works but has no dist URL.
  • A non-git VCS-type dist or source (Mercurial, Subversion, Fossil).

Benchmarks

bench/run.sh <project> [tools] runs hyperfine across scenarios: cold (no cache, no vendor), warm (cache, no vendor), no-op (vendor present), and update-warm (resolve with a warm metadata cache). bench/laravel/ holds a 101-package lock. Results and methodology live in bench/results/.