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.
- I/O Blocking: Every
require_oncetriggers a filesystemstatcall. In a legacy app with 200 includes per request, that is significant I/O overhead before a single line of business logic runs. - Global State Mutation: Manual includes rely on the global scope. If
file_a.phpdefines$config,file_b.phpimplicitly relies on that variable existing. This creates hidden coupling. - Memory Bloat: You often load classes you don't use. If your
bootstrap.phpincludes 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).
- Create a
src/directory. - Move files from
includes/tosrc/. - 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:
- PHP checks if the class
App\Databaseis already defined in memory. It is not. - PHP invokes the registered autoloader (provided by Composer).
- Composer checks its internal map (generated by
dump-autoload). It sees that the prefixApp\maps to the directorysrc/. - Composer calculates the file path:
src/+Database+.php. - Composer executes
include 'src/Database.php'. - 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.
- Code Clarity: You explicitly state dependencies via
usestatements rather than hoping a file was included previously. - Performance: You only parse files strictly required for the specific HTTP request.
- 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.