Skip to main content

Refactoring Legacy PHP: Migrating from Manual Includes to PSR-4 Composer Autoloading

 In legacy PHP ecosystems, the most common architectural debt is the "Include Chain." You open index.php or config.php and find dozens of require_once statements. As the application grows, developers lose track of the dependency graph. Files are included "just in case," or worse, you encounter the "White Screen of Death" because User.php was included before Database.php, but User extends a class defined in Database.

This manual dependency management turns refactoring into a high-risk game of Jenga. It makes unit testing nearly impossible because dependencies are hard-coded into the global scope rather than injected.

This guide details the technical migration from manual require chains to industry-standard PSR-4 autoloading using Composer.

The Root Cause: Why Manual Includes Fail

Under the hood, require and include are procedural instructions to the PHP interpreter to halt execution, read a file from the filesystem, parse it, and inject its contents into the current scope.

  1. I/O Blocking: Every require_once triggers a filesystem stat call. In a legacy app with 200 includes per request, that is significant I/O overhead before a single line of business logic runs.
  2. Global State Mutation: Manual includes rely on the global scope. If file_a.php defines $configfile_b.php implicitly relies on that variable existing. This creates hidden coupling.
  3. Memory Bloat: You often load classes you don't use. If your bootstrap.php includes the entire library of 50 classes but the request only needs three, you have wasted memory and parse time.

Composer’s PSR-4 autoloading utilizes PHP's spl_autoload_register. Instead of loading everything upfront, PHP registers a callback. When the interpreter encounters a class like App\Services\PaymentProcessor that it doesn't recognize, it triggers the callback. Composer then maps the namespace to a file path and includes that specific file just in time.

The Fix: Step-by-Step Migration

We will refactor a legacy module into a PSR-4 compliant structure.

0. The Legacy State

Imagine a typical directory structure from 2012:

/project-root
  /includes
    class.database.php
    class.user.php
    functions.php
  index.php

legacy/index.php:

<?php
require_once 'includes/class.database.php';
// If we forget this next line or change the order, the app breaks
require_once 'includes/class.user.php'; 

$db = new Database();
$user = new User($db);
echo $user->getName();

1. Initialize Composer

If you haven't already, initialize Composer in your project root.

composer init

2. Configure PSR-4 in composer.json

We will map the namespace App\ to a new directory src/. This provides a clean separation between your source code and the entry points (public folder).

Edit composer.json:

{
    "name": "company/legacy-refactor",
    "description": "Migrating legacy code to PSR-4",
    "require": {
        "php": "^8.2"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        },
        "files": [
            "src/helpers.php" 
        ]
    }
}

Note: The files directive is used for procedural function files that cannot be namespaced classes, though these should eventually be refactored into static helper classes.

3. Restructure and Rename

PSR-4 requires that the file path matches the namespace, and the filename matches the class name exactly (case-sensitive).

  1. Create a src/ directory.
  2. Move files from includes/ to src/.
  3. Rename files to remove prefixes like class..

New Structure:

/project-root
  /src
    Database.php  (was includes/class.database.php)
    User.php      (was includes/class.user.php)
    helpers.php   (was includes/functions.php)
  /vendor
  composer.json
  index.php

4. Refactor Class Files

We must add namespaces to the classes. Note that in modern PHP (8.0+), we use constructor property promotion to clean up the syntax.

src/Database.php

<?php

declare(strict_types=1);

namespace App;

use PDO;
use PDOException;

class Database
{
    private PDO $connection;

    public function __construct()
    {
        // In a real app, inject config, don't hardcode
        $dsn = 'mysql:host=localhost;dbname=testdb';
        
        try {
            $this->connection = new PDO($dsn, 'root', 'password', [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
        } catch (PDOException $e) {
            throw new \RuntimeException("Database error: " . $e->getMessage());
        }
    }

    public function getConnection(): PDO
    {
        return $this->connection;
    }
}

src/User.php

<?php

declare(strict_types=1);

namespace App;

// We do NOT require Database.php here.
// The autoloader handles it via the Type Hint.

class User
{
    public function __construct(
        private readonly Database $db
    ) {}

    public function getName(int $id): ?string
    {
        $stmt = $this->db->getConnection()->prepare("SELECT name FROM users WHERE id = ?");
        $stmt->execute([$id]);
        $result = $stmt->fetch();

        return $result['name'] ?? null;
    }
}

5. Generate the Autoloader

Run the following command to generate the vendor/autoload.php file based on your configuration.

composer dump-autoload

6. The New Entry Point

Update your index.php to require only the Composer autoloader.

index.php

<?php

declare(strict_types=1);

use App\Database;
use App\User;

// The only require you will ever need
require_once __DIR__ . '/vendor/autoload.php';

try {
    // Autoloader sees "App\Database", finds src/Database.php, and includes it.
    $db = new Database();
    
    // Autoloader sees "App\User", finds src/User.php, and includes it.
    $user = new User($db);
    
    echo $user->getName(1) ?? 'User not found';

} catch (Throwable $e) {
    // Graceful error handling
    http_response_code(500);
    echo "Application Error: " . $e->getMessage();
}

The Explanation: How It Works

When the line $db = new Database(); executes in index.php:

  1. PHP checks if the class App\Database is already defined in memory. It is not.
  2. PHP invokes the registered autoloader (provided by Composer).
  3. Composer checks its internal map (generated by dump-autoload). It sees that the prefix App\ maps to the directory src/.
  4. Composer calculates the file path: src/ + Database + .php.
  5. Composer executes include 'src/Database.php'.
  6. PHP instantiates the object.

This changes the paradigm from Imperative Loading (Load A, then B, then C) to Lazy Loading (I need C, find it for me).

Handling "Non-Class" Legacy Files

If you have a file full of loose functions (e.g., utils.php) that you cannot immediately refactor into a static class, use the "files" directive in composer.json as shown in Step 2. These files are loaded on every request, mimicking require_once, but managed centrally by Composer.

Conclusion

Migrating to PSR-4 is the single most high-leverage step in modernizing a legacy PHP application.

  1. Code Clarity: You explicitly state dependencies via use statements rather than hoping a file was included previously.
  2. Performance: You only parse files strictly required for the specific HTTP request.
  3. Tooling: Once namespaced, you can immediately leverage tools like PHPStan for static analysis and PHPUnit for testing, which rely heavily on autoloading to mock and reflect classes.

Stop being the linker. Let Composer handle the dependencies so you can focus on the architecture.