Lesson 11 / 12
The Dependency Manager
The manifest file, the version constraint and the lock file; a minor version of an unlocked dependency silently changing the output; the lock file pinning the version; the constraint guarding the major version boundary; semantic versioning being a promise.
Published Updated
In this lesson
- Measures that a minor version of a dependency without a lock file changes the output
- Shows that the lock file pins the version and stops the change
- Distinguishes that a version constraint guards against a major version change but not against minor version behavior
Contents
In the Common Interface Standards lesson the implementation was a class we wrote ourselves. In this lesson the implementation is a package someone else publishes, and the change happens not in your repository but in the release version of the package. The question is the same: where does this change reach?
A package manager holds three things together: the manifest file, which says which packages you depend on, the version constraint, which says which range of versions you accept, and the lock file, which records the exact versions that were resolved. PHP’s common package manager works with these same three parts. This lesson does not run the manager itself; it never reaches the network. The measurement is made with a small resolver that imitates the same three parts in a local directory.
SP4. The resolver in the measurement understands a single constraint of the form ^1.4: greater than or equal to 1.4, less than 2.0.0. The comparison of ^ and ~ is modeled by a separate function. The narrower rule a real manager applies to zero major versions (0.x), resolution across several packages, and the dependencies of dependencies are not modeled.
The Measurement Setup
reach.php is the same as in the earlier lessons: it builds the same program before and after a change and runs both. It asks for the reach in order: if the exit code is not 0 the reach is breaking, if the output differs it is implicit, if the edited file is in the seam list it is at the seam, and if none of these holds it is in place. The earlier version must exit with 0, and the consumer file main.php is not edited.
<?php // reach.php — helpers that measure where a change reaches function write_files(string $dir, array $files): void { foreach ($files as $name => $content) { if ($content === null) { continue; // null: the file was deleted } $path = "$dir/$name"; if (!is_dir(dirname($path))) { mkdir(dirname($path), 0777, true); } file_put_contents($path, $content); } } function read_files(string $dir): array { $files = []; $walker = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) ); foreach ($walker as $file) { $name = substr($file->getPathname(), strlen($dir) + 1); $files[$name] = file_get_contents($file->getPathname()); } return $files; } function run(string $dir): array { $command = 'cd ' . escapeshellarg($dir) . ' && php -d display_errors=0 main.php 2>/dev/null'; exec($command, $lines, $code); return [$code, implode("\n", $lines)]; } function measure(string $name, array $base, array $change, array $seams = []): string { if (array_key_exists('main.php', $change)) { throw new LogicException("$name: the consumer is not edited"); } write_files("measure/$name/before", $base); write_files("measure/$name/after", array_merge($base, $change)); [$codeBefore, $outputBefore] = run("measure/$name/before"); [$codeAfter, $outputAfter] = run("measure/$name/after"); if ($codeBefore !== 0) { throw new LogicException("$name: the baseline did not run"); } $reach = match (true) { $codeAfter !== 0 => 'breaking', $outputBefore !== $outputAfter => 'implicit', array_intersect(array_keys($change), $seams) !== [] => 'at-seam', default => 'in-place', }; printf( "%-18s files=%d exit=%-3d output %-7s -> %s\n", $name, count($change), $codeAfter, $outputBefore === $outputAfter ? 'same' : 'changed', $reach ); return $reach; }
An Unlocked Dependency: Implicit
In the unlocked-version observation, dependencies.json says {"dates": "^1.4"}, that is, it accepts version 1.4 and above of the Dates package, below 2.0.0. resolver.php picks the highest version in the packages directory that fits the constraint and loads the file source.php. main.php formats a date with Dates::format(2026, 10, 15). In the baseline only packages/dates-1.4.0 exists and the resolver picks it. The change: version 1.5.0 of the package is released. No file in your repository is edited; only packages/dates-1.5.0/source.php is added. There is no lock file.
<?php // unlocked.php — a dependency without a lock file takes the new minor version require_once 'reach.php'; // 11. A new minor version of the dependency is released; there is no lock file $reaches[] = measure('unlocked-version', [ 'dependencies.json' => '{"dates": "^1.4"}', 'packages/dates-1.4.0/source.php' => <<<'PHP' <?php class Dates { public static function format(int $year, int $month, int $day): string { return sprintf('%04d-%02d-%02d', $year, $month, $day); } } PHP, 'resolver.php' => <<<'PHP' <?php function pickVersion(string $package, string $constraint): string { $lower = ltrim($constraint, '^'); $upper = ((int) explode('.', $lower)[0] + 1) . '.0.0'; $candidates = []; foreach (glob("packages/$package-*") as $dir) { $version = substr($dir, strlen("packages/$package-")); if (version_compare($version, $lower, '>=') && version_compare($version, $upper, '<')) { $candidates[] = $version; } } usort($candidates, 'version_compare'); return end($candidates); } $requested = json_decode(file_get_contents('dependencies.json'), true); $lock = is_file('lock.json') ? json_decode(file_get_contents('lock.json'), true) : []; foreach ($requested as $package => $constraint) { require "packages/$package-" . ($lock[$package] ?? pickVersion($package, $constraint)) . '/source.php'; } PHP, 'main.php' => <<<'PHP' <?php require 'resolver.php'; echo Dates::format(2026, 10, 15), "\n"; PHP, ], [ 'packages/dates-1.5.0/source.php' => <<<'PHP' <?php class Dates { public static function format(int $year, int $month, int $day): string { return sprintf('%02d.%02d.%04d', $day, $month, $year); } } PHP, ]); echo run('measure/unlocked-version/before')[1], ' -> ', run('measure/unlocked-version/after')[1], "\n"; $names = array_keys(read_files('measure/unlocked-version/before')); sort($names); echo implode(', ', $names), "\n";
unlocked-version files=1 exit=0 output changed -> implicit 2026-10-15 -> 15.10.2026 dependencies.json, main.php, packages/dates-1.4.0/source.php, resolver.php
The reach is implicit: one file was added and the exit code stayed 0, but the output went from 2026-10-15 to 15.10.2026. Not a single line was edited in your repository, the program did not crash, and the date format changed. The resolver picked 1.5.0 as the highest version that fits the constraint; this happened not because the constraint forbade it but because it allowed it. The last line is the file list of the baseline: lock.json is not in the list.
The Lock File: In Place
The counterpart is locked: the same release, but the baseline carries a lock.json file, and the file pins the dates package to version 1.4.0. If the lock file has an entry, the resolver loads that version without looking at the constraint. The change is again the release of version 1.5.0 of the package.
<?php // locked.php — the lock file keeps the pinned version require_once 'reach.php'; $base = read_files('measure/unlocked-version/before'); $base['lock.json'] = '{"dates": "1.4.0"}'; $after = read_files('measure/unlocked-version/after'); measure('locked', $base, [ 'packages/dates-1.5.0/source.php' => $after['packages/dates-1.5.0/source.php'], ]); echo run('measure/locked/before')[1], ' -> ', run('measure/locked/after')[1], "\n";
locked files=1 exit=0 output same -> in-place 2026-10-15 -> 2026-10-15
The reach is in place: one file was added, the exit code stayed 0, and the output stayed 2026-10-15. In the measurement without a lock file the same release had changed the output; here the lock kept the resolver at 1.4.0. The difference is a single file: the decision to pin stands in the lock file, and the new version is taken only when that file is updated.
Two Ways to Write a Constraint: Caret and Tilde
A constraint can be written with two different signs, and the two do not say the same thing. The ^ sign pins the leftmost nonzero part of the version: ^1.4.0 accepts 1.4.0 and above, below 2.0.0. The ~ sign looks at the number of parts written. The three-part ~1.4.0 accepts 1.4.0 and above only below 1.5.0; the two-part ~1.4 gives the same range as ^1.4. The helper file that computes the range is below; the next two measurements use it. This is a source, not a measurement, so it has no output block.
<?php // version.php — reads ^ and ~ constraints as ranges function bounds(string $constraint): array { $sign = $constraint[0]; $parts = array_map('intval', explode('.', substr($constraint, 1))); $lower = implode('.', array_pad($parts, 3, 0)); if ($sign === '^' || count($parts) < 3) { $upper = ($parts[0] + 1) . '.0.0'; } else { $upper = $parts[0] . '.' . ($parts[1] + 1) . '.0'; } return [$lower, $upper]; } function highest(string $constraint, array $versions): ?string { [$lower, $upper] = bounds($constraint); $fits = array_filter( $versions, fn(string $v) => version_compare($v, $lower, '>=') && version_compare($v, $upper, '<') ); usort($fits, 'version_compare'); return $fits === [] ? null : end($fits); }
The same five releases are tested with three constraints. In the output, the first column is the constraint and what follows is the selected version.
<?php // distinction.php — the same releases give different versions under three constraints require_once 'version.php'; $releases = ['1.4.0', '1.4.7', '1.5.0', '1.9.2', '2.0.0']; foreach (['^1.4.0', '~1.4.0', '~1.4'] as $constraint) { printf("%-7s -> %s\n", $constraint, highest($constraint, $releases)); }
^1.4.0 -> 1.9.2 ~1.4.0 -> 1.4.7 ~1.4 -> 1.9.2
All three constraints start at 1.4.0, but ~1.4.0 stays within patch versions and stops at 1.4.7; ^1.4.0 also takes minor versions and climbs to 1.9.2. Dropping one part, that is, writing ~1.4 instead of ~1.4.0, brings the constraint back to the width of ^. None of them takes 2.0.0. The measurement shows that the risk in the unlocked-version observation can be narrowed by the choice of a constraint; but the risk does not disappear, it only descends to patch versions.
A Team in Four Steps
A flow explains why the lock file goes into your repository. The manifest says ^1.4.0, the package is at 1.4.0, and there is no lock file yet. There are two people on the team: you and a friend. The flow uses no file system; it keeps the manifest, the lock entry and the release list of the package in three variables.
<?php // flow.php — when the lock file is in the repository, everyone gets the same version require_once 'version.php'; function install(string $constraint, array $releases, ?string $lock): array { if ($lock !== null) { return [$lock, $lock, 'from lock']; } $version = highest($constraint, $releases); return [$version, $version, 'resolved']; } $constraint = '^1.4.0'; $releases = ['1.4.0']; [$version, $lock, $source] = install($constraint, $releases, null); printf("1 first install %s %s\n", $version, $source); $releases[] = '1.4.7'; [$version, , $source] = install($constraint, $releases, $lock); printf("2 friend, locked %s %s\n", $version, $source); [$version, , $source] = install($constraint, $releases, null); printf("2 friend, unlocked %s %s\n", $version, $source); [$version, $lock, $source] = install($constraint, $releases, null); printf("3 update %s %s\n", $version, $source); [$version, , $source] = install($constraint, $releases, $lock); printf("4 friend again %s %s\n", $version, $source);
1 first install 1.4.0 resolved 2 friend, locked 1.4.0 from lock 2 friend, unlocked 1.4.7 resolved 3 update 1.4.7 resolved 4 friend again 1.4.7 from lock
At the first install there was no lock, the resolver picked 1.4.0, and the lock entry became 1.4.0. When the package released 1.4.7, your friend, who took the lock from the repository, again installed 1.4.0; without the lock they would have installed 1.4.7, and two versions would have run on two machines. The update is the step that knowingly ignores the lock: the resolver picked 1.4.7 and rewrote the lock entry. When your friend took this new entry from the repository, they met you at 1.4.7.
This is the reason the lock file goes into the repository: if the decision to pin is not distributed with the code, every machine does its own resolution. Updating is also a separate edit; the difference in the lock file shows, within a commit, which versions changed.
The Three Parts of Semantic Versioning
The parts that ^ and ~ distinguish are each a promise. The major part increases on a change that is not backward compatible. The minor part increases when a backward-compatible feature is added. The patch part increases on a backward-compatible bug fix. ^ trusts the first two of this promise: it assumes that your code will keep working as long as the major part stays fixed. ~1.4.0 trusts only the third.
In the unlocked-version measurement, the move from 1.4.0 to 1.5.0 was a minor part increase, and the promise would have required the output not to change. Since the date format changed, either the package did not keep its promise or your code depended on something the promise does not cover, in this case the text of the output. This distinction matters in practice: if the promise was broken, the problem is reported to the owner of the package and the constraint is narrowed; if it came from your code, the fix is made in your repository. In both cases you first need to know which one it is, and for that you need to know when the change arrived. The resolver cannot know which one it is. It does not need to: the lock file does not remove this uncertainty, but it makes certain that the change arrives when you decide and with your approval.
This is why the two decisions are made separately. The constraint says in which range you expect your code to work, and it rarely changes. The lock file says which version in that range you use today, and it changes with every update. If you choose the first wrongly, the lock file protects you; if you do not put the lock file in the repository, no constraint keeps you on the same version. The bounding measurement tests what the constraint still guards against when there is no lock.
The Bounding Measurement
A constraint guards against a major version change. This measurement confirms it: there is no lock file, the baseline is the unlocked-version baseline, and the released package is 2.0.0. The ^1.4 constraint leaves 2.0.0 out.
<?php // major-version.php — the constraint guards against a major version change require_once 'reach.php'; $base = read_files('measure/unlocked-version/before'); measure('major-version', $base, [ 'packages/dates-2.0.0/source.php' => <<<'PHP' <?php class Dates { public static function format(int $year, int $month, int $day): string { return "$day/$month/$year"; } } PHP, ]); echo run('measure/major-version/before')[1], ' -> ', run('measure/major-version/after')[1], "\n";
major-version files=1 exit=0 output same -> in-place 2026-10-15 -> 2026-10-15
The reach is in place: 2.0.0 was released, the resolver again picked 1.4.0, and the output stayed 2026-10-15. The ^1.4 constraint guards against a major version change; but it does not guard against a behavior change in a minor version, as the unlocked-version measurement showed. That a minor version will not change the output is a promise called semantic versioning: the owner of the package gives it, and the resolver does not check it. If the promise is kept, the major version boundary is enough; if it is not, only the lock file protects.
Summary
- The reach of this lesson is implicit. In the
unlocked-versionobservation version 1.5.0 of the package was released, no file was edited in your repository, the exit code stayed 0, and the output went from2026-10-15to15.10.2026. - Its counterpart
lockedis in place:lock.jsonheld the version at1.4.0, and the output did not change. - The bounding measurement
major-versionis in place: the^1.4constraint left 2.0.0 out. The constraint guards against the major version but not against the behavior of a minor version. - The
distinctionandflowmeasurements showed two distinctions:~1.4.0stops at a patch version and^1.4.0at a minor version; when the lock file is in the repository, everyone on the team installs the same version, and an update knowingly rewrites the lock. - The manifest file records what you ask for and the lock file records what you got; a version constraint is a range, and semantic versioning is a promise.
Next Step
The package manager gathered in one place which version of a dependency is loaded. But who builds the dependencies of a class? When a dependency changes, which file is edited? The Dependency Injection lesson measures this.
To keep your progress and take notes, Log in
My notes
Log in to take notes.