php-tuf/composer-stager
A PHP library for staging Composer operations in a separate directory before applying them to your project, enabling safer dependency updates, rollback, and testable deployments without touching your active codebase.
composer-stager wraps a Composer operation in a four-phase lifecycle so you never mutate the live codebase until the change has been fully resolved in a scratch directory.
composer require php-tuf/composer-stager
Requirements: PHP 8.1+, a working composer binary reachable from the PHP process (not just your shell), and proc_open enabled.
Where to look first. Start in ComposerStager\API\Core — the four interfaces you will actually call are:
| Interface | Responsibility |
|---|---|
BeginnerInterface::begin() |
create/seed the staging dir from the active dir |
StagerInterface::stage() |
run the Composer command inside staging |
CommitterInterface::commit() |
sync staging back over active |
CleanerInterface::clean() |
delete the staging dir |
Everything under ComposerStager\Infrastructure\ is the default implementation; ComposerStager\API\Enum\Command holds the supported command names (REQUIRE, UPDATE, REMOVE).
The package is DI-first — it is not meant to be hand-wired with new. The repo's examples/ directory contains a standalone container wiring you can copy, and the ComposerFactoryInterface is your entry point for producing a ComposerInterface.
First use case. A deploy-adjacent "apply a security update" job:
use ComposerStager\API\Core\Beginner\BeginnerInterface;
use ComposerStager\API\Core\Cleaner\CleanerInterface;
use ComposerStager\API\Core\Committer\CommitterInterface;
use ComposerStager\API\Core\Composer\ComposerInterface;
use ComposerStager\API\Core\Stager\StagerInterface;
use ComposerStager\API\Enum\Command;
function stagePackageUpdate(
BeginnerInterface $beginner,
StagerInterface $stager,
CommitterInterface $committer,
CleanerInterface $cleaner,
ComposerInterface $composer,
string $activeDir,
string $stagingDir,
string $package,
): void {
$exclusions = ['vendor', 'storage', '.git', 'node_modules'];
$beginner->begin($activeDir, $stagingDir, $exclusions);
try {
$stager->stage(
$composer,
$activeDir,
$stagingDir,
[Command::REQUIRE, $package, '--with-all-dependencies'],
);
$committer->commit($activeDir, $stagingDir, $exclusions);
} finally {
$cleaner->clean($activeDir, $stagingDir, $exclusions);
}
}
One begin() per logical change set. You can call stage() several times between a single begin()/commit() pair — e.g. require the new package, then update its dependencies. Each call shells out to Composer with cwd set to the staging directory; the composer.json/composer.lock state accumulates across calls.
Always wrap in try/finally. clean() is idempotent and safe to call after a failed begin(), so the finally block is your only protection against a stale staging directory blocking the next run (see Gotchas).
Exclusions are how you protect user data. The third argument on begin()/commit() is an array of paths relative to the active dir. Anything excluded is neither copied to staging at begin nor synced back at commit. For a Laravel app the usual set is vendor, node_modules, .git, storage, and any public/uploads-style directory. Note the trade-off: excluding vendor means a composer require will update composer.lock in the active dir but leave vendor/ untouched — you must run composer install afterwards.
Stream output to the user. Implement ComposerStager\API\Core\IO\IOInterface (a single write() method plus a severity constant) and hand it to stage(). In a queued job, write to a log channel; in an artisan command, write to $this->output. Use ComposerStager\Infrastructure\Service\IO\NullIO when you don't care.
Laravel integration shape. Bind the four core interfaces plus ComposerInterface in a service provider, wrap the lifecycle in a ComposerStager service, and invoke it from either a queued job or an artisan command:
Cache::lock('composer-stager', 600)->block(5, function () use ($service) {
$service->require(base_path(), storage_path('app/composer-staging'), 'vendor/package');
});
There is no internal locking — a database/cache lock is mandatory if two workers or two admins can trigger a staging run.
Fake the Composer layer in tests. Implement ComposerInterface with a spy that writes a marker file into the staging dir; you then exercise the full begin/stage/commit/clean cycle against a temporary directory tree without running real Composer.
Pre-flight before begin(). Assert: staging parent is writable, staging dir is absent or empty, free disk ≥ the active dir size, and the active dir is a real Composer project. Doing this up front gives users a clear error instead of a Filesystem exception halfway through.
The staging directory must be empty or non-existent. begin() will not seed into a directory containing leftovers. If a previous run crashed, either call clean() first or make your wrapper do it defensively at the start of every run.
Keep staging on the same filesystem as active. The committer prefers rsync/hard links; a staging dir on a different mount (e.g. storage/app vs /var/www) degrades to a full PHP copy, which is dramatically slower on large trees.
commit() is not atomic. It walks staging and writes file by file. An interrupted commit can leave the active tree half-updated. Take a snapshot/backup (or a git tag) before committing, and treat a commit failure as a serious incident rather than a retryable error.
Exclusion paths have no leading slash and no globs. 'vendor/*' is not a thing; use 'vendor'. Getting this wrong silently excludes nothing, and you'll find out when user uploads get rewritten.
The Composer binary may not be on PATH for the process that runs this. PHP-FPM, systemd units, and queue workers routinely have a different PATH than your login shell. Configure the executable explicitly through ComposerFactoryInterface rather than relying on discovery — a failure here surfaces as a generic "composer not found" process exception.
Composer scripts and plugins run against staging, with cwd = staging. Plugins that resolve paths from realpath(__DIR__) are fine; plugins that hardcode absolute production paths or touch the database will behave unpredictably. Audit post-install-cmd / post-update-cmd before enabling staging on a production clone, and consider --no-scripts for the initial rollout.
Timeouts and memory. This is a subprocess, so PHP's max_execution_time still applies to the waiting parent. Set set_time_limit(0) (or raise it well past your Composer budget) in artisan/job context, and make sure proc_open, exec, and escapeshellarg are not in disable_functions — Symfony Process fails with a confusing message when they are.
Catch ComposerStager\API\Exception\ExceptionInterface, not \Throwable alone. Its getCode() constants distinguish precondition failures (bad dir, wrong state) from Composer failures (non-zero exit), which lets you show an actionable message instead of raw output. Composer's own stderr is available via your IOInterface implementation.
Watch the major version. Most blog posts and Stack Overflow answers describe the 1.x layout (Domain\ / Value\ classes). 2.x moved everything into API\ and replaced several value objects with enums. Pin the major version in composer.json and read the installed interface signatures — stage()'s argument shape in particular has changed.
Windows and containers. Without rsync, commit falls back to PHP filesystem operations: correct but slow, and symlink handling differs. If you're staging inside a container, mount the staging parent as a volume or all work is lost on restart mid-commit.
Extension points worth knowing. Decorate ComposerInterface to inject global flags (mirrors, auth tokens, --prefer-dist), to log every command, or to point at a non-default Composer executable. Decorate CommitterInterface if you need a post-sync hook (cache clear, opcache reset, release marker) — that is the last moment before you declare success.
How can I help you explore Laravel packages today?