Resolution¶
Resolution turns the constraints in phast.toml into the exact versions in phast.lock.
Phast uses the PubGrub algorithm (the same family
as UV and Dart's pub): it learns from every conflict it encounters, never re-explores a
dead end, and when no solution exists it can explain why instead of dumping solver
internals.
Constraint syntax¶
Phast supports Composer's full constraint language:
| Constraint | Meaning | Equivalent range |
|---|---|---|
^1.2.3 |
Compatible, no breaking changes | >=1.2.3 <2.0.0 |
^0.3.4 |
Caret on a 0.x line | >=0.3.4 <0.4.0 |
~1.2.3 |
Patch-level changes | >=1.2.3 <1.3.0 |
~1.2 |
Minor-level changes | >=1.2.0 <2.0.0 |
1.2.* / 1.2.x |
Wildcard | >=1.2.0 <1.3.0 |
>=1.0 <2.0 |
Range (space or comma is AND) | as written |
1.0 - 2.0 |
Hyphen range | >=1.0 <2.1 |
1.0 \|\| 2.0 |
OR | union |
!=1.5 |
Exclusion | everything but 1.5 |
* |
Anything | full range |
dev-main |
A branch, matched exactly | that branch only |
^2.0@dev |
Range + a stability floor for this package | >=2.0.0 <3.0.0, dev releases allowed |
Notes that trip people up:
- Caret on 0.x:
^0.3.4locks the minor version, because the 0.x line is unstable by convention. This matches Composer (and differs from npm). - Tilde precision:
~1.2.3and~1.2.0allow up to the next minor;~1.2allows up to the next major. Whether you wrote the third segment matters, even when it is zero. - Version spellings:
v1.2.3,1.2.3.4(four segments), and stability suffixes in any spelling (1.0.0-beta1,1.0.0-beta.1,1.0.0beta1) all parse and compare correctly.
Stability¶
Every version has a stability derived from its suffix, ordered
dev < alpha < beta < RC < stable. The resolver only ever sees versions at or above
your minimum-stability (default: stable):
Within the allowed set, Phast picks the newest version that satisfies all constraints.
Per-package stability¶
Lowering minimum-stability for the whole project to accommodate one package is a blunt
instrument: it opens every dependency in your tree to pre-releases. A trailing
@stability flag on the constraint sets the floor for that package only:
[config]
minimum-stability = "stable" # everything else stays stable
[require]
"acme/sdk" = "^2.0@dev" # ...but this package may resolve to a dev version
"acme/next" = "@beta" # any version, as long as it is beta or better
The flag is not part of the version range: "^2.0@dev" still means "2.x", it only
changes which 2.x releases are visible. Written without a range ("@beta") it means "any version,
at that stability".
Accepted labels are the same five: dev, alpha, beta, rc, stable. The flag
replaces the project floor for that package in either direction, so on a project with
minimum-stability = "dev" a "^1.0@stable" pins one dependency back to stable
releases.
Two limits worth knowing:
- Only your own requirements count. A flag in
[require]or[require-dev]applies; a flag inside a dependency's own requirements is ignored. Otherwise any package in your tree could quietly lower your project's stability policy from a distance. - In a workspace it is workspace-wide. Members share one lock and one
vendor/, so a flag written by any member applies to that package for the whole workspace, exactly like the merged constraints do.
Adding or removing a flag changes the resolution, so it invalidates phast.lock and the
next phast install re-resolves.
Platform packages¶
php, php-64bit, and ext-* requirements are constraints on the runtime, not
downloadable packages. Phast seeds them before resolving:
- Explicit
[config.platform]entries win. - Otherwise the local
phpbinary is asked for its version and loaded extensions.
A dependency tree requiring ext-mbstring therefore resolves instantly against your
actual runtime, and fails with a clear conflict if the requirement cannot be met,
rather than at runtime in production.
Virtual packages¶
Packages like psr/log-implementation or psr/http-client-implementation do not exist
on any registry; they are interface contracts. Any package whose metadata declares a
matching provide (or replace) satisfies them. Phast resolves these automatically
from the metadata of the real packages in your tree, with backtracking across multiple
candidate providers; no configuration needed.
The root project's own [provide] / [replace]
work the same way: the root satisfies the named package directly (installing nothing
extra for a provide, shadowing the real package for a replace). This covers the
monorepo metapackage pattern, where a root replaces the components it bundles at
self.version.
How install, update, and lock differ¶
All three run the same solver with different inputs:
| Command | Existing lock is used as | Effect |
|---|---|---|
phast install (stale lock) |
preferences | changed constraints move; everything else stays |
phast update |
ignored | everything moves to the newest allowed version |
phast update <pkg> |
hard pins for all other packages | only the named packages can move |
phast lock --refresh |
preferences | forced re-resolve with minimal churn |
A preference means "use this version if it still fits"; a pin means "this version, or fail". Pins are what make selective updates safe: if updating your target would force an unrequested change elsewhere, you get an error naming the conflict instead of a surprise upgrade.
When resolution fails¶
Conflicts are reported in terms of your project, with the competing requirements lined up:
Phast could not find compatible versions for your requirements
╰─▶ symfony/polyfill-mbstring has incompatible constraints:
· your project requires symfony/polyfill-mbstring[>=99.0, <100.0]
· symfony/http-foundation[>=7.0, <8.0] requires symfony/polyfill-mbstring[>=1.1, <2.0]
Other failure shapes are reported just as directly: a package that does not exist on
any configured registry, or one that exists but has no version satisfying the combined
constraints. phast why <package> is the companion tool for understanding where a
problematic requirement comes from.
Performance¶
Resolution speed comes from never waiting on the network when avoidable: metadata for the dependencies of every resolved package is prefetched in the background while the solver works, each package is fetched at most once per run, results are cached on disk and revalidated cheaply (see Caching), and a warm lock skips resolution entirely.