In a reproduced Composer 2.10.2 lab, the project validated, wrote a lock file, and passed a dry install against PHP 8.1.99 plus a synthetic extension. The destination actually ran PHP 8.4.24 and did not have that extension. composer check-platform-reqs --lock --no-dev exited 2, and loading vendor/autoload.php exited 255.
Both results were correct. Composer had resolved the project against the platform declared in config.platform; the later checks asked what the machine could really execute. A migration can therefore look dependency-complete while the new host remains unable to start the locked release.
This guide is for an agency or freelancer deciding whether a PHP destination is ready for client traffic. It builds a disposable negative control, repairs it, compares CLI and HTTP-served runtime identities, and produces a cutover receipt. The lab never changes a production PHP package, virtual host, DNS record, database, or live release.
Composer calls PHP, extensions such as ext-mbstring, system libraries, and Composer itself platform packages. They are not downloaded into vendor/ like ordinary PHP libraries. The destination must supply them.
Migration work exposes at least three relevant identities:
Above those layers, an application smoke test proves behavior above those platform layers. Composer cannot prove database credentials, writable storage, queue workers, secrets, external APIs, schema migrations, or representative traffic. Keep those gates separate.
| Evidence object | What it sees | Required pass condition | What it still does not prove |
|---|---|---|---|
| Dependency solve and lock | Resolver platform, including config.platform |
One reviewed dependency set for the intended PHP range | The destination installed that PHP or its extensions |
check-platform-reqs --lock --no-dev |
Actual CLI PHP against the production lock | Every required production platform row succeeds | The web handler uses the same runtime |
Generated platform_check.php |
PHP loading the built autoloader | The intended vendor tree loads without a platform exception | Application routes, data, or external services work |
| Protected web probe | Actual destination virtual host and handler | Expected release, PHP, SAPI, and extension booleans | Business transactions are correct |
| Application smoke test | Selected application paths | Read/write/queue and dependency-specific acceptance passes | Full performance or every user journey |
Responsibility matters as much as the command. Use a managed-versus-unmanaged VPS responsibility map to name whether the agency, client, or provider owns PHP packages, INI differences, and the final sign-off. “The host supports PHP” is not an owner or an acceptance criterion.
Destination acceptance should use the exact composer.json and composer.lock from the release candidate. Do not run composer update on the destination: that asks for a new dependency decision and can produce a different release. Build or install from the reviewed lock, record its SHA-256, and test with production scope.
--lock tells check-platform-reqs to inspect requirements from the lock rather than whatever happens to be installed in vendor/. --no-dev deliberately removes development-only packages from the production decision. If the release actually ships development dependencies, do not add --no-dev merely to make a red result green; define the intended artifact first.
Below, the disposable lab downloads the current stable Composer PHAR, verifies it against Composer’s official SHA-256 endpoint, records versions, and creates an ownership-checked cleanup trap. Run all six input blocks in the same Bash session. An existing receipt path causes an early stop rather than an overwrite.
set -Eeuo pipefail
LAB_DIR="$(mktemp -d /tmp/voxfor-composer-platform-178.XXXXXX)"
MARKER="$LAB_DIR/.voxfor-owned"
COMPOSER="$LAB_DIR/composer.phar"
RECEIPT="$LAB_DIR/receipt.txt"
RECEIPT_COPY="$PWD/composer-platform-receipt-178.txt"
SERVER_PID=""
cleanup() {
if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID"; wait "$SERVER_PID" 2>/dev/null || true
fi
if [[ -d "${LAB_DIR:-}" && -f "${MARKER:-}" ]] \
&& [[ "$(<"$MARKER")" == "voxfor-composer-platform-178" ]] \
&& [[ "$LAB_DIR" == /tmp/voxfor-composer-platform-178.* ]]; then
find "$LAB_DIR" -depth -mindepth 1 -delete
rmdir "$LAB_DIR"
fi
}
trap cleanup EXIT
for command_name in curl php sha256sum awk grep; do
command -v "$command_name" >/dev/null
done
[[ ! -e "$RECEIPT_COPY" ]]
printf '%s\n' 'voxfor-composer-platform-178' > "$MARKER"
export COMPOSER_ALLOW_SUPERUSER=1 COMPOSER_NO_INTERACTION=1
curl -fsSL --retry 3 https://getcomposer.org/download/latest-stable/composer.phar \
-o "$COMPOSER"
COMPOSER_SHA256="$(curl -fsSL --retry 3 \
https://getcomposer.org/download/latest-stable/composer.phar.sha256)"
printf '%s %s\n' "$COMPOSER_SHA256" "$COMPOSER" | sha256sum -c -
COMPOSER_VERSION="$(php "$COMPOSER" --version --no-ansi | awk 'NR==1 {print $3}')"
PHP_VERSION="$(php -r 'echo PHP_VERSION;')"
printf 'environment\tcomposer=%s\tcomposer_sha256=%s\tphp_cli=%s\tphp_sapi=%s\tkernel=%s\n' \
"$COMPOSER_VERSION" "$COMPOSER_SHA256" "$PHP_VERSION" \
"$(php -r 'echo PHP_SAPI;')" "$(uname -r)" | tee "$RECEIPT"
cd "$LAB_DIR"
Production pipelines should pin and authenticate their Composer version rather than silently following “latest.” The moving download is appropriate here only because the receipt records both version and hash and the exercise is disposable. Keep the platform gate in the same release workflow that has an explicit rollback, after the artifact is frozen and before the traffic switch.
Composer’s config.platform setting is useful when dependency resolution must target an older production PHP version than the build machine runs. It fakes platform packages for the resolver; it does not install that PHP or extension on a server.
Our fixture requires real php and ext-json packages plus the deliberately nonexistent ext-voxfor_marker. Its platform section claims PHP 8.1.99 and version 1.0.0 of the synthetic extension. That controlled lie should let resolution succeed. The negative control is valuable because it proves the later destination gate is not just repeating the resolver’s assumptions.
composer update appears here only to manufacture the disposable fixture’s lock. On a real migration, copy the reviewed application lock to the destination and begin with composer install; never regenerate client dependencies to make the host fit.
cat > "$LAB_DIR/composer.json" <<'JSON'
{
"name": "voxfor/composer-platform-acceptance",
"description": "Disposable Composer destination-platform acceptance fixture",
"license": "proprietary",
"type": "project",
"require": {
"php": "^8.1",
"ext-json": "*",
"ext-voxfor_marker": "*"
},
"config": {
"platform": {
"php": "8.1.99",
"ext-voxfor_marker": "1.0.0"
},
"platform-check": true
}
}
JSON
php "$COMPOSER" validate --strict --no-check-publish --no-ansi \
> "$LAB_DIR/negative-validate.txt"
php "$COMPOSER" update --no-interaction --no-plugins --no-scripts \
--no-audit --no-ansi > "$LAB_DIR/negative-update.txt"
php "$COMPOSER" install --dry-run --no-dev --no-interaction --no-plugins \
--no-scripts --no-ansi > "$LAB_DIR/negative-dry-run.txt"
[[ -s "$LAB_DIR/composer.lock" ]]
[[ -s "$LAB_DIR/vendor/composer/platform_check.php" ]]
grep -F 'ext-voxfor_marker' "$LAB_DIR/composer.lock" >/dev/null
NEGATIVE_LOCK_SHA256="$(sha256sum "$LAB_DIR/composer.lock" | awk '{print $1}')"
printf 'simulated_model\tconfig_platform_php=8.1.99\tfaked_extension=ext-voxfor_marker\tvalidate=accepted\tdry_install=accepted\tlock_sha256=%s\n' \
"$NEGATIVE_LOCK_SHA256" | tee -a "$RECEIPT"
Success at this row proves only that the model is internally solvable. It is not a destination acceptance result. The same warning applies when someone reaches for --ignore-platform-reqs or a selective --ignore-platform-req: those flags can help isolate a diagnostic problem, but a bypass is not evidence that production can execute the release.
Composer documents check-platform-reqs as a check of the real platform that ignores config.platform. The first command below reads the lock, removes development scope, and asks for machine-readable output. It must return nonzero and identify the synthetic extension as missing.
Next, load the generated autoloader. With platform-check enabled, Composer’s generated vendor/composer/platform_check.php is required during autoload. In this lab it must fail too. Capturing both controls guards against an operator who ran only the resolver, and against a deployment wrapper that hid one command’s return status.
set +e
php "$COMPOSER" check-platform-reqs --lock --no-dev --format=json \
> "$LAB_DIR/negative-check.json" 2> "$LAB_DIR/negative-check.err"
NEGATIVE_CHECK_RC=$?
php -r 'require "vendor/autoload.php"; echo "autoload=accepted\n";' \
> "$LAB_DIR/negative-runtime.txt" 2>&1
NEGATIVE_RUNTIME_RC=$?
set -e
[[ "$NEGATIVE_CHECK_RC" -ne 0 ]]
[[ "$NEGATIVE_RUNTIME_RC" -ne 0 ]]
php -r '
$rows = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
$missing = array_values(array_filter($rows, static fn($row) =>
($row["name"] ?? "") === "ext-voxfor_marker"
&& ($row["status"] ?? "") === "missing"));
if (count($missing) !== 1) exit(1);
' "$LAB_DIR/negative-check.json"
grep -F 'Composer detected issues in your platform' \
"$LAB_DIR/negative-runtime.txt" >/dev/null
printf 'negative_control\tcheck_rc=%s\tmissing=ext-voxfor_marker\tautoload_rc=%s\tactual_php=%s\n' \
"$NEGATIVE_CHECK_RC" "$NEGATIVE_RUNTIME_RC" "$PHP_VERSION" \
| tee -a "$RECEIPT"
Treat the exact nonzero value as observed evidence, not a portable contract. Gate on success versus failure and parse the named JSON rows; do not encode “return code must always be 2” into every deployment system. Also archive stderr when a wrapper or plugin could change output.
Real failures should lead to one of three actions: install or enable the required extension in the correct SAPI, select a PHP runtime that satisfies the lock, or return to dependency review and deliberately produce another release. Deleting a true requirement from composer.json without code-level evidence merely moves the failure past the gate.
Repairing the lab removes only the synthetic requirement and declares two extensions actually present on the test host: JSON and Mbstring. It deliberately retains simulated PHP 8.1.99. A positive result must report the real PHP 8.4.24, mark all three lock rows successful, and load the autoloader.
cat > "$LAB_DIR/composer.json" <<'JSON'
{
"name": "voxfor/composer-platform-acceptance",
"description": "Disposable Composer destination-platform acceptance fixture",
"license": "proprietary",
"type": "project",
"require": {
"php": "^8.1",
"ext-json": "*",
"ext-mbstring": "*"
},
"config": {
"platform": {"php": "8.1.99"},
"platform-check": true
}
}
JSON
php "$COMPOSER" update --no-interaction --no-plugins --no-scripts \
--no-audit --no-ansi > "$LAB_DIR/accepted-update.txt"
php "$COMPOSER" validate --strict --no-check-publish --no-ansi \
> "$LAB_DIR/accepted-validate.txt"
php "$COMPOSER" install --no-dev --no-interaction --no-plugins --no-scripts \
--no-ansi > "$LAB_DIR/accepted-install.txt"
php "$COMPOSER" check-platform-reqs --lock --no-dev --format=json \
> "$LAB_DIR/accepted-check.json"
php -r '
$rows = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
$byName = [];
foreach ($rows as $row) {
$byName[$row["name"]] = $row;
if (($row["status"] ?? "") !== "success") exit(1);
}
foreach (["ext-json", "ext-mbstring", "php"] as $required) {
if (!isset($byName[$required])) exit(1);
}
if (($byName["php"]["version"] ?? "") !== PHP_VERSION) exit(1);
' "$LAB_DIR/accepted-check.json"
php -r 'require "vendor/autoload.php"; echo "autoload=accepted\n";' \
> "$LAB_DIR/accepted-runtime.txt"
grep -Fx 'autoload=accepted' "$LAB_DIR/accepted-runtime.txt" >/dev/null
DESTINATION_LOCK_SHA256="$(sha256sum "$LAB_DIR/composer.lock" | awk '{print $1}')"
printf 'destination_check\tconfig_platform_php=8.1.99\treal_php=%s\text_json=success\text_mbstring=success\tautoload=accepted\tlock_sha256=%s\n' \
"$PHP_VERSION" "$DESTINATION_LOCK_SHA256" | tee -a "$RECEIPT"
This positive fixture again uses update only because its requirements changed inside the lab. A production acceptance run must not modify the frozen lock. Record the expected lock hash before transfer, compare it at the destination, and refuse a mismatch.
CLI success still cannot speak for PHP-FPM or Apache. Different packages, service unit environments, INI scan directories, and pool settings can produce a website failure after a clean shell check. Even a healthy PHP-FPM worker pool is a separate execution boundary from CLI PHP.
Now prove the comparison mechanism with PHP’s loopback cli-server. Both paths require the same vendor autoloader and return release, PHP, SAPI, and extension identities. Because the built-in server uses the same PHP binary, parity is expected; it is not evidence about your PHP-FPM or Apache handler.
cat > "$LAB_DIR/probe.php" <<'PHP'
<?php
require __DIR__ . '/vendor/autoload.php';
header('Content-Type: application/json');
echo json_encode([
'release' => 'composer-platform-178',
'php_version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'required_extensions' => [
'json' => extension_loaded('json'),
'mbstring' => extension_loaded('mbstring'),
],
], JSON_UNESCAPED_SLASHES);
PHP
cat > "$LAB_DIR/cli-probe.php" <<'PHP'
<?php
require __DIR__ . '/vendor/autoload.php';
echo json_encode([
'release' => 'composer-platform-178',
'php_version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'required_extensions' => [
'json' => extension_loaded('json'),
'mbstring' => extension_loaded('mbstring'),
],
], JSON_UNESCAPED_SLASHES);
PHP
php "$LAB_DIR/cli-probe.php" > "$LAB_DIR/cli-probe.json"
PORT=18784
if curl -fsS --max-time 1 "http://127.0.0.1:$PORT/probe.php" >/dev/null 2>&1; then
printf 'Refusing occupied loopback port %s\n' "$PORT" >&2
exit 1
fi
php -S "127.0.0.1:$PORT" -t "$LAB_DIR" > "$LAB_DIR/php-server.log" 2>&1 &
SERVER_PID=$!
for _ in {1..100}; do
if curl -fsS --max-time 1 "http://127.0.0.1:$PORT/probe.php" \
-o "$LAB_DIR/web-probe.json" 2>/dev/null; then break; fi
sleep 0.05
done
[[ -s "$LAB_DIR/web-probe.json" ]]
php -r '
$cli = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
$web = json_decode(file_get_contents($argv[2]), true, 512, JSON_THROW_ON_ERROR);
foreach ([$cli, $web] as $row) {
if (($row["release"] ?? "") !== "composer-platform-178"
|| ($row["required_extensions"]["json"] ?? false) !== true
|| ($row["required_extensions"]["mbstring"] ?? false) !== true) exit(1);
}
if ($cli["php_version"] !== $web["php_version"]) exit(1);
printf(
"runtime_parity\tcli_sapi=%s\tweb_sapi=%s\tphp=%s\trequired_extensions_match=yes\n",
$cli["sapi"], $web["sapi"], $cli["php_version"]
);
' "$LAB_DIR/cli-probe.json" "$LAB_DIR/web-probe.json" | tee -a "$RECEIPT"
For real acceptance, place a one-time, access-controlled probe inside the exact candidate release and request it through the destination virtual host. Restrict it by a random path plus network or authentication control, return no secrets or full phpinfo(), verify the intended hostname and TLS identity before DNS with a curl connection-and-request identity test, then delete the probe immediately. Compare the CLI and web results; do not require the SAPI labels to be equal, because cli and fpm-fcgi are expected to differ. Require the release, PHP constraint, and extension booleans to match the acceptance contract.
Preserve the evidence outside the disposable directory, stop the loopback server, and remove only the marker-owned lab.
cp "$RECEIPT" "$RECEIPT_COPY"
cleanup
trap - EXIT
[[ ! -e "$LAB_DIR" ]]
printf 'cleanup\tlab_absent=yes\n' | tee -a "$RECEIPT_COPY"
This complete run was reproduced twice on 2026-08-14. The two receipts matched in Composer/PHP versions, PHAR hash, lock hashes, negative results, destination success, and runtime parity; only their intentional receipt-copy paths differed.
environment composer=2.10.2 composer_sha256=5ee7125f8a30a34d246cefdc0bc85b8a783b28f2aec968994118512350d28027 php_cli=8.4.24 php_sapi=cli kernel=6.12.96+deb13-amd64
simulated_model config_platform_php=8.1.99 faked_extension=ext-voxfor_marker validate=accepted dry_install=accepted lock_sha256=5d1a086b82262aa28df2b8da8051fc2cf296061aa0c02b97bb171fa04b396842
negative_control check_rc=2 missing=ext-voxfor_marker autoload_rc=255 actual_php=8.4.24
destination_check config_platform_php=8.1.99 real_php=8.4.24 ext_json=success ext_mbstring=success autoload=accepted lock_sha256=0043610bc1364e01c162473583d26b24fe156cded41fe8b696d37014d2bf47bc
runtime_parity cli_sapi=cli web_sapi=cli-server php=8.4.24 required_extensions_match=yes
cleanup lab_absent=yes
Accept the disposable reproduction only when the Composer PHAR matches the official hash, the simulated fixture validates and dry-installs, the real lock check names the synthetic extension as missing, the generated autoloader also rejects it, the repaired fixture reports the actual PHP plus successful JSON and Mbstring rows, autoload succeeds, the CLI and HTTP probes carry the expected release and true extension booleans, and marker-owned cleanup leaves only the saved receipt. Accept a production destination only after the same frozen lock hash passes there and a protected probe through its actual web handler satisfies the release-specific PHP and extension contract.
If any platform row, lock hash, autoload, release identity, or handler probe fails, do not move traffic: preserve the receipt, keep the current host and release authoritative, stop the temporary server, and let the ownership-checked trap remove only the lab. If failure appears after a live switch, restore the previously tested route or release pointer, verify the old application health and data authority, and investigate the destination without using ignore flags as acceptance. Where rollback depends on an archive, measure the backup file-count and restore-time risk before the window.
Client handoff records should include release identifier, composer.lock SHA-256, Composer and PHP versions, hostname, CLI SAPI, web SAPI, every required extension result, probe URL scope, timestamp, operator, approver, and exact rollback target. Add separate receipts for database/schema, writable paths, queue workers, scheduled jobs, mail, object storage, external APIs, and representative requests.
When the hosting contract includes server-side PHP configuration and migration support, confirm whether the provider owns extension installation and web-handler changes. The agency still owns the application’s declared requirements, release identity, acceptance evidence, and client decision unless the written scope says otherwise.
composer install prove the destination meets PHP requirements?Not by itself. Installation can be influenced by config.platform, ignore flags, an existing vendor tree, plugin behavior, or the runtime that launched Composer. Use the frozen production lock with check-platform-reqs --lock --no-dev, preserve its result, and test the same release through the actual web handler.
config.platform do?It tells Composer’s dependency resolver to behave as if specified PHP, extension, or library versions exist. That can keep a lock compatible with a target platform, but it does not install or enable those components on the destination. Treat it as a dependency-model input, not host inventory.
--lock and --no-dev?--lock evaluates the requirements recorded for the frozen release rather than relying on the current vendor directory. --no-dev deliberately checks only the production package set. Omit --no-dev when development packages are genuinely part of the deployed artifact; the scope must match what will run.
vendor/composer/platform_check.php enough?No. It is a valuable early runtime guard generated into the autoloader, and the lab shows it can reject a missing extension. It does not prove every application subsystem, data migration, process, handler, or external service. Keep the explicit CLI result and application acceptance tests too.
CLI and the web server may use different PHP binaries, INI scan directories, enabled extensions, environment variables, or service accounts. Run a minimal protected probe through the exact destination virtual host, compare it with CLI, and remove it after the result is captured.
Ignore flags are useful for narrow diagnosis or for building an artifact in a deliberately different environment, but they are not a destination acceptance strategy. Before traffic moves, install the required platform capability, select a compatible release, or document a code-backed reason why a declared requirement is incorrect and change it through normal review.
Record the artifact or release ID, lock hash, host, Composer version, CLI PHP/SAPI, web PHP/SAPI, required extension rows, autoload result, protected application probe, time, operator, approver, and rollback target. Attach separate data, queue, storage, network, and transaction checks because Composer does not cover them.
Destination readiness does not follow because Composer found a solvable dependency graph. The destination is ready for the PHP-platform portion of cutover when the reviewed lock, real CLI, generated runtime guard, and actual web handler all identify the same acceptable release—and when a named owner can return traffic to the previous known-good target.
Keep that receipt with the migration record. At the next PHP upgrade, extension change, base-image rebuild, control-panel switch, or host move, rerun the same contract against the new destination instead of inheriting yesterday’s green result.