---
title: 'The Dependency Manager'
source: 'https://academia.sh/en/courses/php-object-model/dependency-manager'
course: 'Object-Oriented PHP and Standards'
language: en
updated: '2026-10-21T05:03:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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
<?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
<?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
<?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
<?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
<?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
<?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
<?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-version` observation 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 from `2026-10-15` to `15.10.2026`.
- Its counterpart `locked` is **in place**: `lock.json` held the version at `1.4.0`, and the output did not change.
- The bounding measurement `major-version` is **in place**: the `^1.4` constraint left 2.0.0 out. The constraint guards against the major version but not against the behavior of a minor version.
- The `distinction` and `flow` measurements showed two distinctions: `~1.4.0` stops at a patch version and `^1.4.0` at 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.
