Skip to content
LibxaFrame
Sign In
Technical 8 August 2026 7 min read

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.

Keep reading