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
| Command | What it does | When |
|---|---|---|
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:
| Class | File |
|---|---|
Ledger\Invoice | src/Invoice.php |
Ledger\Billing\Invoice | src/Billing/Invoice.php |
Ledger\Billing\Invoice\Repository | src/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.
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.