Why your lock file resolves differently than your manifest says
The starter kit declared PHP ^8.3 and locked packages needing 8.4.1. Three separate settings were causing it, each correct on a maintainer’s machine and wrong everywhere else, with no error message to say so.
The starter kit's composer.json declared "php": "^8.3". Its composer.lock
was full of packages requiring 8.4.1. Both files were committed, CI was green,
and installs worked: right up until someone ran it on 8.3.
Three separate things were causing it. Each is worth knowing on its own.
1. A global stability flag loosens everything
The kit needed the framework at dev-main while the two were developed
together. The obvious way to allow that is:
{
"minimum-stability": "dev"
}
That works, and it also quietly says every dependency may resolve to a dev version. One package needed to be unstable; the flag made all of them eligible.
The fix is per-package stability, which says exactly what you mean:
{
"minimum-stability": "stable",
"require": {
"libxa/framework": "^0.10.0 || dev-main@dev"
}
}
@dev applies to that constraint alone. Everything else resolves stable.
2. Composer resolves for the PHP you are running
This is the one that produced the mismatch. Composer resolves against the PHP
version of the machine doing the resolving, not the version in your require
block. Run composer update on 8.4 and you get a lock file full of packages
that need 8.4, even though your manifest says ^8.3, and even though Composer
never warns you.
config.platform.php fixes it by telling Composer what to pretend it is
running:
{
"config": {
"platform": {
"php": "8.3"
}
}
}
Now composer update on any machine produces a lock file that installs on 8.3,
which is what the manifest promised. Set this to the lowest version you
support, not your development version.
3. A canonical path repository hides Packagist
While developing the framework and the kit side by side, a path repository points the kit at the local checkout:
{
"repositories": [
{ "type": "path", "url": "../libxaframe" }
]
}
Path repositories are canonical by default. Canonical means: if this
repository has the package, do not look anywhere else. Locally that is what you
want. Published, it means Packagist is never consulted for that package, and
anyone installing the kit gets a resolution failure, because ../libxaframe
does not exist on their machine.
{
"repositories": [
{ "type": "path", "url": "../libxaframe", "canonical": false }
]
}
With canonical: false, the local copy is used when it is there and Packagist
is used when it is not. The same composer.json works in both places.
Catching it next time
The lock file records where each package came from. If the framework's entry
has a path dist, the lock was generated against the local checkout and is not
publishable. That is a test:
public function testLockFileDoesNotRecordAPathDist(): void
{
$lock = json_decode(file_get_contents(__DIR__ . '/../composer.lock'), true);
foreach ($lock['packages'] as $package) {
$this->assertNotSame(
'path',
$package['dist']['type'] ?? null,
"{$package['name']} was locked from a local path: regenerate the lock "
. 'with the sibling checkout hidden.',
);
}
}
It runs in CI, so a lock file regenerated on the wrong machine fails a pull request instead of a stranger's install.
The general lesson
All three of these are the same shape: a setting that is correct on a
maintainer's machine and wrong everywhere else, with no error message on the
maintainer's machine to say so. Anything in composer.json that behaves
differently depending on who runs it is worth a second look, and worth a test
if you can write one.