Skip to content
Hack Your WorldSoftware · Infrastructure · Home automation

Analysis

A 174-Line Temporary PHP Fix Reached 20 Million Downloads. Now It’s Everyone Else’s Migration.

A small software component spreading across packages, servers, and websites
AI image: Hack Your World

In 2014, Jake Smith wrote 174 lines of PHP so an AOL content-management system could move from PHP 5.2 to 5.3 without changing dozens of URL-building calls. The replacement function was supposed to last a year or two.

Twelve years later, jakeasmith/http_build_url has nearly 20 million Packagist downloads, was still adding more than 400,000 in a month, and has just been marked abandoned.

Packagist downloads are not 20 million distinct production installations. CI systems and repeated Composer installs count too. The number still tells me the package is moving through an enormous amount of software for something its author considered a stopgap.

The more important number may be the installations Packagist cannot count.

The dependency escaped the dependency manager

Smith published the package as a polyfill for http_build_url(), a function supplied by version 1 of the pecl_http extension. His implementation defines the function only when it does not already exist, allowing old application code to continue without knowing which implementation handled the request.

That is a good compatibility technique. It is also how temporary code becomes load-bearing. The call sites stay unchanged, the migration succeeds, and the reason the shim exists fades out of operational memory.

The package spread through normal Composer dependencies, including the idna-convert domain-name library. It consequently appears in SPIP’s source and as packages in Debian and Ubuntu. WPML took another route: according to Smith, the WordPress multilingual plugin bundles the polyfill directly and says it runs on more than 1.5 million sites.

Bundling matters. Composer can warn that a named package is abandoned. It cannot warn about source copied into a plugin years ago unless that product’s maintainer carries the message downstream. A software-composition scanner may identify the copied code if it has a good fingerprint; a dependency list generated from composer.lock may not show it at all.

I have maintained enough PHP applications to distrust the idea that composer show describes the whole runtime. WordPress plugins carry vendor trees, distributions ship patched source, applications keep compatibility files outside Composer, and old deployment processes sometimes copy only selected directories. The manifest is evidence, not ground truth.

A bug that removes every “a” is funny until it changes a URL

One known defect explains why “it has worked for years” is weak evidence.

The polyfill contains a trailing-slash workaround that appends an a to a path, performs its join, and then removes that temporary suffix with a replacement. Under a particular trailing-slash case, the replacement removes every lowercase a from the path, not only the sentinel it added.

Smith is not fixing it. The package will remain installable, but version 1.0.2 marks it abandoned and the README directs users to PHP 8.5’s built-in URI API or league/uri on older PHP versions.

Leaving the bug in place sounds uncomfortable, but changing a twelve-year-old URL function used through unknown call patterns is not obviously safer. Some applications may have tests that encode the current behavior. Others may depend on edge cases that the package never specified. A one-line repair can be correct in isolation and still break downstream systems nobody can enumerate.

Deprecation is the honest state: the implementation is no longer a maintained foundation, existing releases remain available, and downstream owners have to choose when and how to leave.

Handing over a popular package is not a neutral act

Smith asked for a successor in 2021 and received volunteers. A death in his family interrupted the process, he moved away from PHP work, and the handoff never happened. That human history is part of the dependency graph even though no lock file can express it.

It would be easy to turn that into a lecture about maintainers owing the ecosystem a succession plan. I do not think that is useful. The package was free, permissively licensed, small, and public. Its author did not force millions of downstream downloads or promise lifetime maintenance.

There is also real risk in transferring a widely used package simply to make the maintenance status look healthier. A new release from a trusted package name runs automatically in build systems around the world. Ownership is therefore a security boundary. A hurried handoff can be more dangerous than a clearly abandoned package whose hash and behavior are known.

The safe options are slower: downstream projects can fork and pin the code they are prepared to own, migrate to a supported URI implementation, or remain on the final release while accepting and documenting the risk. None produces the comforting fiction that one new volunteer has inherited everyone else’s operational responsibility.

Finding it requires more than one command

I would split the inventory into managed, transitive, and copied dependencies.

For Composer projects, composer why jakeasmith/http_build_url identifies the package path when it is present in the resolved graph. composer audit and abandoned-package reporting should be part of CI, but the current lock file and the exact Composer version used by production need to be included. A stale build image can preserve an older result long after a developer workstation has moved on.

Then I would search the deployed source itself, including plugin and vendor directories, for function http_build_url, HTTP_URL_JOIN_PATH, and HTTP_URL_JOIN_QUERY. That catches direct copies Composer cannot name.

On WordPress, I would search the actual plugin release installed in production rather than assume its upstream repository matches the deployed artifact. On Debian or Ubuntu, I would also query the package database for php-jakeasmith-http-build-url and inspect reverse dependencies before removal.

Finally, I would trace behavior. Search for every call, record which flags are used, and build tests from real URLs before replacing the function. URI resolution has enough edge cases—relative paths, empty queries, fragments, encoding, ports, authority components, and duplicate query keys—that a syntactically simple replacement can change output.

PHP 8.5 provides an exit, not an automatic migration

PHP 8.5 now includes a standards-based URI API under Uri\Rfc3986\Uri. That removes the original reason for this polyfill on current runtimes. Applications on older PHP versions can use a maintained library such as league/uri, or implement a narrow replacement with parse_url() and http_build_query() when their requirements are genuinely simple.

None is a drop-in promise for every combination of flags supported by http_build_url(). The migration should compare outputs against application expectations, not merely replace one function name with another.

The package’s own origin is the caution: it existed because changing dozens of call sites during a PHP upgrade looked riskier than recreating an old function. That decision was reasonable in 2014. The mistake would be treating its success as proof that the temporary layer no longer needs an owner.

This is what dependency debt usually looks like. Not an obviously reckless library, but a small competent fix that solved the immediate problem so well that everyone stopped seeing it.

Sources