PHP projects have small dependency trees mainly because the PHP engine allows exactly one definition of every class name per process. That rule turns every version constraint into a decision shared by all packages in a project, and it forces PHPUnit's PHAR to rename its dependencies. Andrew Nesbitt has written about the opposite case: why npm dependency trees are so big. The usual explanations are the missing standard library, the culture, and the laziness of package authors. His answer is the resolver. PHP does not appear in his text, but his argument carries over to PHP step by step.
The conflict error sends the bill to the right person
Nesbitt starts with Ruby. Bundler picks one version of every gem for the entire application. Every version constraint in every gem is a claim on that shared choice. When the claims cannot be reconciled, Bundler refuses to install and names the gems whose constraints collided. That refusal turns into an issue on the right tracker, a maintainer widens a range and ships, and after every major Rails release a wave of such small releases rolls through the ecosystem. A constraint is a cost that other people pay, and the error message sends the bill to the one person who can lower it. Gem authors react accordingly: short dependency lists, wide ranges.
npm has no such error, and the reason sits in the runtime. Node resolves modules by file path. Two copies of the same package in different node_modules directories are two different files, and nothing in the runtime objects to that. When two packages want incompatible versions of a shared dependency, npm gives each of them its own copy and the installation succeeds. A constraint therefore costs its author nothing. No conflict forces two maintainers to talk to each other, and a dependency on a ten-line package is as cheap as any other. That is the precondition the micro-package habit needed. The costs are still there, they are only paid elsewhere: every duplicated copy is installed, bundled, and added to the surface somebody has to audit. “Dependency trees grow to the size their resolver permits”, Nesbitt writes.
Composer never had a choice
PHP sits on Bundler's side of that line, and more firmly than Ruby does. Composer picks exactly one version of every package per project. Its solver either finds a set of versions that satisfies all constraints, or it refuses and prints the sentence every PHP developer has met: “Your requirements could not be resolved to an installable set of packages”, followed by the names of the packages that disagree.
Composer's authors were never free to decide this differently. The PHP engine keys loaded code by fully qualified class name. One definition per name per process; declaring the same name a second time is a fatal error. PSR-4 autoloading maps each class name to exactly one file. Nested node_modules directories have no equivalent in PHP because there is nothing for the nesting to accomplish. However many copies of a library sit on disk, only one of them can ever be loaded under its name. Composer resolves to a single version per package because the runtime leaves it no alternative.
The social mechanism follows as Nesbitt describes it for Ruby. A new major version of Symfony sets off a wave of releases across Packagist that do little more than widen a constraint. A library that pins tightly collects issues, because its pin blocks other people's upgrades. PHP maintainers keep dependency lists short and ranges wide for the same reason gem authors do: the conflict error makes sure they hear about it when they don't.
The standard library only explains the demand
You may object that I have skipped the obvious explanation. PHP ships with a comprehensive standard library, implemented in C, an intrinsic part of the language runtime. String manipulation, array operations, date arithmetic, JSON, hashing, database access: all there before the first composer require. A package like is-odd has no market in PHP, because the language already answers the question. JavaScript shipped for years with almost nothing, and thousands of ten-line npm packages filled that vacuum.
I accept the objection, but it only covers half of the problem. The standard library explains the demand for micro-packages; the resolver explains why depending on them is free. Rust separates the two factors. Its standard library is deliberately minimal, its resolver is strict within a semver-compatible range, and it lands in the middle: Nesbitt cites Armin Ronacher counting a basic Rocket web project at 172 crates, while foundational crates like serde have been on the same major version since 2017, because a breaking release would split the single version every dependent has to share. A small standard library with a strict resolver produces medium-sized trees; with a duplicating resolver it produces npm.
You can watch the two factors come apart in JavaScript itself. The language grew String.prototype.padStart in 2017, a year after the left-pad incident, and the micro-packages it made redundant are still downloaded millions of times a week. Demand created those packages, and the resolver keeps them alive.
PHP has both factors on the restrictive side, a corner it shares with Python: batteries included, one version per process. That is why a typical PHP application resolves dozens of packages where a comparable JavaScript application resolves hundreds. It is not that we are more disciplined.
PHPUnit lives in your process
A test framework runs inside the same process as the code it tests. Your code under test and the framework's code are loaded into one engine, one class table, one definition per name.
When you install PHPUnit with Composer, the strict model works as intended. There is one dependency tree, and PHPUnit's dependencies are resolved together with yours: one version of sebastian/diff, shared by the framework and by whatever else in the project happens to use it. It also comes with the cost Nesbitt describes, and the bill does not always reach the right person straight away. On the day Symfony 3.1 was released, somebody asked me in #2180 to raise PHPUnit's requirement for symfony/yaml. That requirement already allowed Symfony 3.1. The conflict sat two levels further down: PHPUnit required phpspec/prophecy, Prophecy required version 2 of phpdocumentor/reflection-docblock, and that version did not get along with Symfony 3.1. Composer printed the whole chain, and the fix had to come from Prophecy's maintainers. Sometimes I send the bill to myself. PHPUnit 7.0 required version 3 of sebastian/diff, phpcov 4.0.5 required version 1 or 2, and anybody who used both tools in the same project could not upgrade (#2990). Both constraints were mine.
The PHAR distribution of PHPUnit cannot take part in this arrangement. It brings a fixed set of dependencies into a process whose dependency tree it has never seen. There, the engine's rule applies: whichever copy is loaded first owns the name. The second one either dies with a fatal error or is never loaded at all, and code runs against classes from the wrong version. In December 2015, #2014 described how a globally installed PHPUnit loaded classes from its own environment before those of the project, and the test suite died with a fatal error because GuzzleHttp\Client no longer matched the GuzzleHttp\ClientInterface that had already been loaded. Jordi Boggiano pointed to php-scoper even then, and the next day I opened #2015: move every bundled dependency into a namespace of its own. Until that was done, I had only one piece of advice. When the PHAR failed in a Symfony 2 project in September 2016 because the classes of symfony/yaml were declared twice (#2285), my answer was not to use the PHAR in a situation like that.
So the PHAR does what npm does, reconstructed by hand: it renames. During the build, php-scoper prefixes the namespaces of all bundled dependencies, and SebastianBergmann\Exporter becomes PHPUnitPHAR\SebastianBergmann\Exporter. Two versions of the same library can then coexist in one process, because as far as the engine can tell they are different code. Getting there took more than three years. Sebastian Feldmann built the prefixing into the build in #3575, based on earlier work by Théo Fidry and others, I merged it in March 2019, and every PHAR since PHPUnit 8.3 in August 2019 has been built that way. How seriously the engine takes the renaming showed up in the very first test. When Sebastian Feldmann ran PHPUnit's own test suite with the new PHAR, one test failed with a TypeError: a method expected a PHPUnit\SebastianBergmann\Comparator\ComparisonFailure and was given a SebastianBergmann\Comparator\ComparisonFailure. To the engine, the same code under two names is two different classes.
The same asymmetry shows up in npm's escape hatch. npm duplicates by default, so it needed peerDependencies for the packages that break when they are duplicated. The famous case is React, where two copies in one application break hooks, because the state they rely on lives in a module. PHP shares by default, so it needs deliberate build machinery wherever duplication is what you want. Each ecosystem builds by hand what the other one gets for free.
The PHAR discloses what it duplicates
Duplication has a price, and the PHAR pays it knowingly. Every bundled copy is one more component that ships, and one more entry on the surface somebody has to audit. That is why it keeps a record of its copies. --manifest lists every bundled package with its version, --sbom emits a CycloneDX document, and --composer-lock outputs the lock file the PHAR was built from. I have described this in my article on the MG4 case. The prefix renames namespaces; the packages and their versions in the bill of materials stay the same. npm's duplicated copies accumulate quietly in the depths of node_modules. The PHAR's are renamed, listed, and signed.
The same reasoning applies to the other direction, the dependencies PHPUnit takes on. Every package I add to PHPUnit's composer.json becomes a constraint that every project using PHPUnit has to live with. symfony/yaml was such a package. PHPUnit needed it for a single class, the TAP logger, which emitted its diagnostics as YAML, and still every Symfony project had to agree with PHPUnit on one version of that component. The dependency went away together with the TAP logger in PHPUnit 6.0 in February 2017.
With symfony/process, I decided against the dependency, although PHPUnit's process isolation would have benefited from it. In 2014, I still agreed with the proposal in #1342 to replace PHPUnit's own code around proc_open() with the Symfony component. In 2020, I closed the ticket without it ever having been implemented. A component that is installed in almost every Symfony project anyway would have tied PHPUnit to the versions those projects use, and those projects to the versions PHPUnit can handle. So I keep maintaining the code for process isolation myself. For the same reason PHPUnit's tree is shallow, and most of the sebastian/* packages depend on nothing but PHP itself.