— Article — № 012

012 —PHP

Composer and the legacy PHP site you just inherited

You inherited a WordPress install with three plugins, three vendored Guzzles, and zero composer.json. Composer init is the wrong first move. Here is the audit that comes first.

Oak tool chest drawer half open on dark workbench, brass pulls, bone-handled awls in felted compartments, paper tag on ring.
Hero · staged still№ 012

The Loom came in at 23:41. Twenty-six minutes, no edit. The agency lead screen-shared a WordPress install they had inherited from a former in-house team, sized at roughly 320MB of PHP outside of wp-content/uploads. Three custom plugins, each carrying its own /lib/ directory: one with Guzzle 5, one with Guzzle 6, one with what appeared to be a hand-edited fork of stripe-php from 2018. No composer.json anywhere. The Stripe integration needed to handle SCA properly before the next billing cycle. Their first instinct, reasonable and almost correct, was to run composer init and start over.

This is the post I send when someone asks why we do not just modernise first. Composer is one of the best things to happen to PHP. It is also, on a legacy PHP site, the wrong first move.

Composer assumes a world that legacy PHP never lived in

Composer's autoloader works because every package agrees on PSR-4, declares its namespace, and ships a composer.json. Run composer require guzzlehttp/guzzle on a clean Laravel project and it just works. The same command on a 2014-era WordPress site can produce a fatal at the first request:

PHP Fatal error: Cannot redeclare class GuzzleHttp\Client
in /wp-content/plugins/abc-integration/lib/guzzle/src/Client.php on line 41

The legacy plugin includes its own Guzzle via require_once __DIR__ . '/lib/guzzle/autoload.php' on plugin load. Composer's autoloader, registered after WordPress boots, finds the same fully-qualified class name pre-declared and dies. There is no clean fix from Composer's side. You can rename namespaces with php-scoper, prefix the older copy, or rip out the legacy include. Each path is real work, and on a site with a hundred deployed plugins you will be making this decision repeatedly.

The legacy site, in other words, has its own autoload story. Sometimes seven of them. Composer does not replace those stories. It adds an eighth.

Inventory before you touch the autoloader

Before any composer init, do the boring thing first. List every vendored library, every version string you can find, and every place a global function or class is declared. This sounds tedious. It takes a morning. It saves a week.

On the filesystem:

find . -path '*/vendor/*' -name 'composer.json' 2>/dev/null
find . -path '*/lib/*' -name '*.php' | xargs grep -l 'const VERSION' 2>/dev/null
grep -rE "define\(\s*['\"][A-Z_]+_VERSION" wp-content/ 2>/dev/null

Run those three and you have a candid picture of what is actually loaded. Half the time you find two copies of the same library at different versions, both being included, with the second one winning because it loads later. That is not a Composer problem. That is a site that needs to be mapped.

For class collisions, the cheapest scan is:

grep -rE "^(class|interface|trait) [A-Z]" wp-content/plugins/ \
  | awk '{print $1, $2, $3}' | sort | uniq -c | sort -rn | head -20

If the top of the list shows class names declared in three files, that is your next ticket. Fix the collisions before introducing a tool that assumes there are none.

Composer alongside, not over

Once the audit is done, you have two honest options. Convert everything to Composer (a project, not a task) or run Composer alongside the legacy code (a smaller, often better, scope).

The alongside pattern looks like this. Create a composer.json at the project root with a custom vendor-dir so it does not collide with anything already on disk:

{
  "config": {
    "vendor-dir": "vendor-managed"
  },
  "require": {
    "stripe/stripe-php": "^13.0"
  },
  "autoload": {
    "psr-4": {
      "Acme\\Integration\\": "wp-content/mu-plugins/acme-integration/src/"
    }
  }
}

Include vendor-managed/autoload.php from a single mu-plugin, and only the code you control uses it. The legacy plugins keep their own include chains. The new Stripe integration gets a current SDK. Nothing else moves. You can ship that change on a Tuesday afternoon and still go for dinner.

What this costs

You give up Composer's main promise, which is that it manages all your dependencies. You will still have the old Guzzle 5 sitting in abc-integration/lib/, unpatched, doing its thing. That is a known unknown rather than a hidden one. Document it, add it to the upgrade backlog, and move on. The alternative, which is rewriting six plugins to share one autoloader, is the work the previous owner postponed for ten years. You inherited the postponement, not the obligation.

The .htaccess detail nobody mentions

When you do introduce Composer to a shared-hosting WordPress, deny direct access to the vendor directory. The default install will happily serve vendor-managed/composer/installed.json if a curious attacker requests it, and that file is a dependency fingerprint they can match against the PHP security advisories database in about thirty seconds.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteRule ^vendor-managed/ - [F,L]
</IfModule>

<FilesMatch "(composer\.(json|lock)|\.env)$">
  Require all denied
</FilesMatch>

Test with curl -I https://yoursite.test/composer.json and confirm a 403. This is not Composer's fault. It is a deployment habit that the modern framework world handles via document-root conventions and that WordPress shared hosts mostly do not.

When the answer is not yet

Sometimes the right call is to not introduce Composer at all on a given site. A custom PHP application running PHP 7.4, three vendored libraries, no active feature work. Adding Composer changes the deployment story (you now need composer install in CI, or a committed vendor-managed/) and gains very little. Track the dependencies in a plain text inventory, pin them in Git, and let the site stay boring. Boring is a feature on a legacy site that has worked for a decade.

The audit work above does not go away if you skip Composer. The vendored copies still need to be inventoried, the colliding classes still need to be fixed, the .htaccess still needs to deny the wrong paths. Composer adoption is a downstream decision. The upstream work is knowing what is actually on the server.

Where this lands

When we built Pier we ran into this across roughly forty customer sites, almost none of which had a composer.json that matched what was on disk. The way we ended up handling it was to put the live filesystem and the live MySQL editor in the same window with full version history, so the audit happens where you are already editing.

The smallest thing you can do today: run the three find and grep commands above on the site that is worrying you, paste the output into a doc, and read it. You will not fix anything in those fifteen minutes. You will know what you are dealing with, which is more than most inherited sites get.

— Questions —

Can I just composer require new packages on a legacy WordPress site?

Sometimes. If the new package's classes do not collide with anything already vendored, yes. Grep for the namespace first, and use a custom vendor-dir so the autoloader stays isolated.

What is the safest way to upgrade a vendored Stripe SDK from 2018?

Do not replace it in-place. Install the new version via Composer into a separate vendor-dir, swap the include in one integration point, and keep the old copy on disk until every code path is tested.

Should I commit the vendor directory on a legacy site?

On shared hosting without SSH, yes. On a host that runs composer install in deploy, no. The deciding factor is whether your deploy pipeline can run Composer reliably, not preference.

Does php-scoper actually solve the class collision problem?

For libraries you control or fork, yes. It prefixes namespaces at build time so two versions can coexist. For plugin code you do not own, it usually creates more maintenance than it saves.