Moving a legacy PHP application onto Composer and PSR-4

The bootstrap file was 240 lines of require_once and nobody would touch it. Not because it was complicated — because the order mattered, nobody knew why any particular line sat where it did, and moving one had once taken the site down for an afternoon. This is how that got dismantled, one namespace at a time, with the application working after every step.

The symptom

Adding any third-party library meant editing the include chain by hand and finding the right position in it by trial and error. Two classes had been given the same name in different directories, so one of them could never be loaded on the same request as the other. And a file removed six months earlier was still being required, guarded by a file_exists() someone had added instead of deleting the line.

require_once __DIR__ . '/lib/Db.php';
require_once __DIR__ . '/lib/Session.php';   // must be after Db
require_once __DIR__ . '/lib/User.php';      // must be after Session
// ... 237 more

Those comments are the real problem. Load order encodes a dependency graph that nothing enforces, so it is correct only as long as everyone remembers it.

Why it happens

Manual includes make file position load-bearing. A class that extends another must be required after its parent, a file that calls a function at the top level must come after the definition, and neither constraint is written down anywhere except as an ordering. Autoloading inverts this: a class is loaded at the moment it is first used, so the order is derived from actual usage rather than maintained by hand.

The obstacle in a legacy codebase is that autoloading needs a rule mapping a class name to a file path, and legacy code has no such rule — User.php defines User, but db_helpers.php defines four classes and a dozen functions.

The fix

A classmap as the safety net

The first move is not PSR-4. It is a classmap, which requires no naming convention at all: Composer scans the directories, records which file defines which class, and writes a lookup table. Nothing has to be renamed or moved, and it can go in on day one.

{
    "autoload": {
        "classmap": [ "lib/", "models/", "controllers/" ]
    }
}
$ composer dump-autoload
Generating autoload files

$ head -10 vendor/composer/autoload_classmap.php
return array(
    'Cart'    => $baseDir . '/lib/Cart.php',
    'Db'      => $baseDir . '/lib/Db.php',
    'Invoice' => $baseDir . '/models/Invoice.php',
    'Report'  => $baseDir . '/models/Report.php',
    'Session' => $baseDir . '/lib/Session.php',
    'User'    => $baseDir . '/lib/User.php',
);

That generated file is also the cheapest audit anyone had ever run on this codebase. The two colliding class names appeared as one entry rather than two, so the duplicate was visible as a missing row. Four classes nothing referenced any more showed up as entries in a list, which is a great deal easier to notice than a file nobody thought to open.

With the map in place, the bootstrap can be emptied in batches. The change to the front controller is the whole point of the exercise:

// index.php — before
require_once __DIR__ . '/lib/Db.php';
require_once __DIR__ . '/lib/Session.php';
require_once __DIR__ . '/lib/User.php';
require_once __DIR__ . '/lib/Cart.php';
// ... 236 more, in an order nobody may change

$app = new Application();

// index.php — after
require_once __DIR__ . '/vendor/autoload.php';

$app = new Application();

Delete them in groups of twenty rather than all at once, loading every entry point after each group. The ones that break are the files doing something other than declaring a class, and finding them twenty at a time is far easier than finding them all at the end.

Warning

A classmap is a snapshot. Adding a new class means regenerating it, which is fine in production and infuriating in development. Add a psr-4 entry alongside it from the start so new code needs no rebuild — the two coexist, and the classmap is consulted first.

Then PSR-4, namespace by namespace

With the classmap holding everything up, individual areas can move to a namespace at whatever pace suits. Pick one with few dependents, add the namespace declaration, move the files, and update the callers. Nothing else in the application has to know.

{
    "autoload": {
        "psr-4": { "Shop\": "src/" },
        "classmap": [ "lib/", "models/", "controllers/" ]
    }
}

The two duplicate class names resolved themselves during this, because they landed in different namespaces and became ShopBillingReport and ShopCatalogueReport. Neither had to be renamed, which meant no call site outside those two areas changed.

The functions that cannot be namespaced

Autoloading is triggered by an unresolved class name. Functions have no such hook, so a helpers file has to be loaded eagerly. Composer has an entry for exactly this, and it is the correct home for the handful of global functions a legacy application always has.

{
    "autoload": {
        "psr-4": { "Shop\": "src/" },
        "classmap": [ "lib/", "models/" ],
        "files": [ "src/helpers.php" ]
    }
}

Everything in files is included on every request, so the list must stay short and the files must contain declarations only. Anything in there that executes — a session start, a database connection, a header — has just become impossible to avoid in a CLI script or a test.

Verifying it worked

The check that matters is that every entry point still boots. A codebase of this age has more of them than the routing table suggests: cron scripts, an admin directory, three one-off imports somebody left in the web root.

$ find . -name '*.php' -maxdepth 2 -not -path './vendor/*' 
    -exec php -l {} ; | grep -v 'No syntax errors'

$ for f in index.php admin/index.php cron/*.php; do
>   php -r "require '$f';" >/dev/null 2>&1 || echo "FAILED: $f"
> done
FAILED: cron/rebuild-sitemap.php

That one had been requiring a file deleted in the autumn, guarded by file_exists(), and had therefore been silently doing nothing for five months. The migration did not break it; it revealed it.

Tip

Before deploying, run composer dump-autoload --optimize. Without it, PSR-4 resolves each class with filesystem checks on every request — a few hundred stat calls on a normal page. With it, every lookup is an array read.

What this costs

The diff is enormous and almost entirely mechanical, which is the worst possible shape for code review: too large to read properly, too boring to read carefully. Split it by namespace and merge each one separately, even though that means more deploys.

The classmap also has to stay for a long time. It is tempting to treat it as a temporary scaffold, but the last few files are always the worst ones — a 2,000-line class defining three others below it, a file whose name does not match anything it contains. Those can sit in the classmap indefinitely, and there is no prize for removing the entry.