Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 03 / 15 25 MIN

PHP in 2026

Composer, Autoloading and Project Layout

How Composer manages dependencies and builds an autoloader, what belongs in composer.json versus composer.lock, and why only public/ may be the document root.

A colleague clones the repository, runs the project, and gets Failed opening required 'vendor/autoload.php'. The vendor directory was never committed, which is correct, and the instructions they followed never mentioned running anything to build it. The bug is not in their checkout. It is in a project that has a dependency manager and no reproducible way to use it.

Composer is the tool that decides what the project depends on, installs it, and generates the code that finds your classes. Those are two jobs in one binary, and separating them in your head explains most of what follows, including why the lock file matters more than the manifest.

Composer is a dependency manager and an autoloader

The first job is familiar if you have used npm, pip or Bundler: read a manifest, resolve a set of versions, download packages into a directory. Most people skip the second job until it breaks. Composer writes a small autoloader, and including one generated file makes every class in your project and every class in your dependencies loadable on demand, with no require statements anywhere in your code.

That matters because PHP has no module system of its own. Before Composer, a project carried a file of require_once lines, ordered by hand, and adding a class meant editing that list. The autoloader replaces the list with a rule: a class name maps to a file path, and PHP asks you for the file only if the class is not already loaded. The rule is a convention, and Composer implements the one called PSR-4.

Include the generated entry point once, at the top of the front controller:

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Ledger\Invoice;

$invoice = new Invoice('INV-1042', 129900);

echo $invoice->total(), PHP_EOL;

After that line, new Invoice(...) works because the autoloader finds src/Invoice.php and includes it. That path came from a PSR-4 rule you wrote in composer.json, which is the next piece.

composer.json describes intent, composer.lock records fact

The manifest is composer.json. It holds the package name, the PHP version constraint, the dependencies with version ranges, and the autoloading rules. A version range is a wish, not a result.

{
    "name": "wpsifero/ledger",
    "type": "project",
    "require": {
        "php": ">=8.3"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.0"
    },
    "autoload": {
        "psr-4": {
            "Ledger\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Ledger\\Tests\\": "tests/"
        }
    },
    "config": {
        "sort-packages": true
    }
}

composer.lock is the result. It records the exact version and content hash of every package that was actually installed, including the transitive ones Composer picked to satisfy your ranges. Both files go in the repository, and they do different work: the manifest says what the project needs, the lock file says which combination was tested.

This is where PHP differs from the JavaScript advice you may have read. For an application, composer.lock is committed. composer install reads the lock file and installs exactly those versions, so your laptop, your colleague's laptop and the CI runner get the same code. Run composer install after cloning, and the missing-autoloader problem from the opening paragraph disappears. composer update ignores the lock file, resolves fresh versions inside your ranges and rewrites it, which is a deliberate act with a commit attached, not something to run reflexively.

The vendor directory itself is never committed. It is generated, it is large, and Composer adds it to .gitignore when it initialises a project. Deployments run composer install --no-dev --optimize-autoloader: the first flag skips require-dev packages such as the test framework, and the second produces a class map instead of relying on file existence checks at runtime.

Three commands cover almost everything

CommandWhat it doesWhen
composer require Adds a dependency to composer.json, resolves it, installs it and rewrites the lock file. Adding a package. Commit both files together.
composer install Installs the versions recorded in composer.lock, then generates the autoloader. Cloning, deploying, and after pulling changes to the lock file.
composer dump-autoload Regenerates the autoloader from the current rules without touching packages. After adding a class in a new namespace, or after editing a PSR-4 rule.

There is one piece of folklore here worth correcting. Composer caches file paths for PSR-4 lookups where it can, and a class you just created in a directory that did not exist before is sometimes not found until you regenerate. If a new class is mysteriously missing while its neighbours load, composer dump-autoload is the first thing to try, and it costs a second. The other flag worth knowing is --optimize-autoloader, which scans and indexes every class up front; it makes lookups faster in production and slower to generate, which is the correct trade for a deploy.

PSR-4 turns a namespace into a path

The rule has two halves and they compose simply. A prefix maps to a base directory, and the rest of the class name becomes the path under it, with backslashes replaced by directory separators and .php appended. One rule, "Ledger\\": "src/", therefore means:

ClassFile
Ledger\Invoicesrc/Invoice.php
Ledger\Billing\Invoicesrc/Billing/Invoice.php
Ledger\Billing\Invoice\Repositorysrc/Billing/Invoice/Repository.php

The mapping is mechanical, which is the useful part: you can predict the file from the class name without opening anything. Here is the rule implemented by hand, which is roughly what the generated autoloader does with more indexing around it:

<?php
declare(strict_types=1);

spl_autoload_register(static function (string $class): void {
    $prefix = 'Ledger\\';

    if (!str_starts_with($class, $prefix)) {
        return;
    }

    $relative = substr($class, strlen($prefix));
    $path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';

    if (is_file($path)) {
        require $path;
    }
});

use Ledger\Invoice;
use Ledger\Invoice\Repository;

$invoice = new Invoice('INV-1042', 129900);
echo $invoice->number, ' ', $invoice->total(), PHP_EOL;

echo (new Repository())->find('INV-1042'), PHP_EOL;

Run that against src/Invoice.php and src/Invoice/Repository.php and it prints the invoice number, the formatted total and the repository result. Note the callback shape: it returns quietly when the class is not one of ours, which lets other autoloaders on the stack handle their own prefixes. That is exactly how Composer coexists with the autoloader that a framework or an older library registers.

The vendor autoloader, for the record, is vendor/autoload.php. It requires vendor/composer/autoload_real.php, builds and registers a class loader, and returns that loader instance, so $loader = require __DIR__ . '/../vendor/autoload.php'; is legal if you need to add a path at runtime. You rarely do.

PSR-4 strictness is a feature

A PSR-4 rule expects the namespace prefix to match the directory exactly, including case. A file at src/invoice.php declaring Ledger\Invoice works on a case-insensitive filesystem such as the default on macOS and fails in production on Linux. If a class loads locally and not on the server, check the case of the filename before you check anything else.

Only public/ belongs in the document root

A conventional layout separates what the web server may serve from what it must never serve:

ledger/
  bin/
    sync-invoices.php
  public/
    index.php
    assets/app.css
  src/
    Invoice.php
    Invoice/Repository.php
  tests/
    InvoiceTest.php
  vendor/
  composer.json
  composer.lock
  phpunit.xml

public/ holds the front controller and static assets, and it is the only directory the web server points at. src/ holds the application code that the autoloader maps. tests/ mirrors src/ closely enough that you can find the test for a class without searching, and it is mapped by a separate autoload-dev rule so the test namespace never ships. bin/ holds command-line entry points, each one a small file that requires the autoloader and calls into src/, so cron jobs and console scripts share the same code as the web path.

Now the part that is a security requirement rather than a preference. If the document root is the project root, then https://example.com/.env is a request for a file that exists, and the web server returns it. There is no PHP involved, no framework checking permissions, and no warning anywhere. The same is true for composer.json, composer.lock, phpunit.xml, and every file under src/ that does not happen to be PHP that only echoes nothing. Pointing the server at public/ removes the entire class of problem, because the files are not under the document root at all.

This is also the reason routing exists in the first place. The opening paragraph of the previous lesson described a router script that sends everything without a matching file to one front controller. With public/ as the root, that front controller is public/index.php, paths like /invoices/INV-1042 reach it, and nothing outside public/ is addressable. The layout and the routing rule are the same decision made twice.

The cost is a small amount of ceremony. A request path no longer matches the file that handles it, which surprises people the first time, and you need a web server configuration that sets the root and forwards unmatched paths to index.php. That is one block of configuration in nginx or Apache, and it is the configuration a framework would ask you for anyway.

Lab 25 minutes

Build the layout from this lesson without installing anything from Packagist. You need PHP only.

Create composer.json with a PSR-4 rule mapping Ledger\ to src/ and a require entry for php. Write src/Invoice.php with a Ledger\Invoice class holding a number and a total in cents, and a total() method that formats the amount to two decimals. Write src/Billing/Invoice/Repository.php with a Ledger\Billing\Invoice\Repository class that returns a fixed invoice for a given number. Write public/index.php that requires the autoloader and prints both results.

Since Composer is not installed on every machine, write bootstrap.php with a hand-rolled spl_autoload_register() that implements the same PSR-4 rule, and have public/index.php require that instead of vendor/autoload.php.

Serve the project twice with php -S: once with the project root as the document root, once with public/. Create a file called secret.txt at the project root and request it at /secret.txt in both cases. Record the two responses.

Show the solution and the reasoning

The autoloader is the same one from the lesson, moved into its own file, because the front controller should not care how classes are found:

<?php
declare(strict_types=1);

spl_autoload_register(static function (string $class): void {
    $prefix = 'Ledger\\';

    if (!str_starts_with($class, $prefix)) {
        return;
    }

    $relative = substr($class, strlen($prefix));
    $path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';

    if (is_file($path)) {
        require $path;
    }
});

Replacing vendor/autoload.php with a hand-written loader is a teaching device, not a production pattern. It works for exactly the same reason the real one does: the rule is a pure function from class name to path. What it does not have is Composer's class map, its support for files and classmap rules, and its handling of dependencies that do not follow PSR-4 at all. Those are the reasons the generated autoloader exists.

For the layout, the two classes land at src/Invoice.php and src/Billing/Invoice/Repository.php. That second path looks like a contradiction until you apply the rule: everything after the Ledger\ prefix becomes the path, so Billing\Invoice\Repository becomes Billing/Invoice/Repository.php. The name is long on purpose, because a namespace that mirrors the directory structure is a namespace you can navigate.

The two server runs are the point of the exercise. With the project root as the document root, GET /secret.txt returns the file's contents with a 200 and a text/plain content type. The web server did its job. With public/ as the document root, the same request cannot resolve to a file, so it falls back to index.php, and the response is whatever the front controller prints, with no trace of secret.txt in it. Nothing was configured to block that path. It simply is not under the root.

The remaining decision is what happens to public/index.php when the application grows. Keeping it as a front controller that parses the path and dispatches is the shape every framework uses, and doing it by hand once, as this lab asks, is the cheapest way to stop treating routing as magic. The trade-off is that a hand-written dispatcher accumulates special cases quickly; once the path list passes a handful of entries, a router belongs in src/ where it can be tested, and the front controller should shrink back to requiring the autoloader and calling one function.

Progress

Mark this lesson done and it stays marked in this browser.