PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Up to this point every example fit in a single file. Real projects have dozens or hundreds of classes, depend on third-party libraries, and must deal gracefully with things going wrong. In this part you will learn the tools that make professional PHP projects possible: namespaces, Composer, PSR-4 autoloading, and exceptions.
Namespaces
Namespaces prevent name collisions. Your User class and a library's User class can coexist because they live in different namespaces, just like two files with the same name in different folders. Declare the namespace at the top of the file, right after declare(strict_types=1).
<?php
// src/Billing/Invoice.php
declare(strict_types=1);
namespace App\Billing;
final class Invoice
{
public function __construct(
public readonly string $number,
public readonly int $amount,
) {}
}
To use a class from another namespace, import it with use. Classes in the same namespace need no import. Built-in classes live in the global namespace, so inside a namespace you either import them or prefix them with a backslash.
<?php
// src/Billing/InvoiceFormatter.php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable; // global class: import it (or write \DateTimeImmutable)
use App\Support\Money as MoneyHelper; // alias with "as"
final class InvoiceFormatter
{
public function format(Invoice $invoice): string // same namespace: no import needed
{
$date = (new DateTimeImmutable('2026-01-15'))->format('d M Y');
return "{$invoice->number} ({$date}): " . MoneyHelper::rupiah($invoice->amount);
}
}
Composer: The PHP Package Manager
Composer installs libraries (called packages) from Packagist and generates an autoloader. Install it from getcomposer.org, then create a project and add packages:
mkdir invoice-app && cd invoice-app
composer init --name="example/invoice-app" --no-interaction
composer require nesbot/carbon
composer require --dev phpunit/phpunit
Composer records your dependencies in composer.json and the exact installed versions in composer.lock. Commit both files; never commit the vendor/ folder. Teammates run composer install to get identical versions.
{
"name": "example/invoice-app",
"require": {
"php": "^8.2",
"nesbot/carbon": "^3.8"
},
"require-dev": {
"phpunit/phpunit": "^11.5"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
The version constraint ^3.8 means "3.8 or newer, but below 4.0", so you get bug fixes and new features without breaking changes. Packages under require-dev are only needed during development, such as testing tools.
PSR-4 Autoloading
Before autoloading, PHP projects were full of require statements. PSR-4 is a standard that maps a namespace prefix to a directory. With "App\\": "src/", the class App\Billing\Invoice is expected at src/Billing/Invoice.php. Note that inside JSON the backslash must be escaped as \\.
invoice-app/
βββ composer.json
βββ composer.lock
βββ public/
β βββ index.php
βββ src/
β βββ Billing/
β β βββ Invoice.php -> App\Billing\Invoice
β β βββ InvoiceFormatter.php -> App\Billing\InvoiceFormatter
β βββ Support/
β βββ Money.php -> App\Support\Money
βββ tests/
βββ vendor/ (generated, do not commit)
Here is the remaining helper class:
<?php
// src/Support/Money.php
declare(strict_types=1);
namespace App\Support;
final class Money
{
public static function rupiah(int $amount): string
{
return 'Rp ' . number_format($amount, 0, ',', '.');
}
}
After changing the autoload section, regenerate the autoloader:
composer dump-autoload
Output:
Generating autoload files
Generated autoload files
Now a single require of vendor/autoload.php loads every class on demand:
<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Billing\Invoice;
use App\Billing\InvoiceFormatter;
$invoice = new Invoice('INV-001', 1250000);
echo (new InvoiceFormatter())->format($invoice) . PHP_EOL;
php public/index.php
Output:
INV-001 (15 Jan 2026): Rp 1.250.000
The rule is strict: one class per file, the file name must exactly match the class name (case-sensitive on Linux), and the folder structure must match the namespace.
Exceptions
When something goes wrong that the current code cannot handle, throw an exception. The caller can catch it and decide what to do. The finally block runs whether or not an exception occurred, which is perfect for cleanup.
<?php
declare(strict_types=1);
function divide(int $a, int $b): float
{
if ($b === 0) {
throw new InvalidArgumentException("Cannot divide by zero");
}
return $a / $b;
}
try {
echo divide(10, 4) . "\n";
echo divide(10, 0) . "\n"; // throws, the next line never runs
echo "Unreachable\n";
} catch (InvalidArgumentException $e) {
echo "Error: " . $e->getMessage() . "\n";
} finally {
echo "Finally always runs\n";
}
try {
echo intdiv(1, 0);
} catch (DivisionByZeroError $e) {
echo get_class($e) . ": " . $e->getMessage() . "\n";
}
Output:
2.5
Error: Cannot divide by zero
Finally always runs
DivisionByZeroError: Division by zero
Everything that can be thrown implements Throwable. Knowing the hierarchy helps you catch the right things:
Throwable
βββ Error (engine errors: TypeError, ValueError, DivisionByZeroError, ...)
βββ Exception
βββ LogicException (bugs in your code: InvalidArgumentException, DomainException, ...)
βββ RuntimeException (problems at runtime: UnexpectedValueException, PDOException, ...)
Custom exceptions and chaining
Create your own exception classes to express domain problems. Named constructors keep messages consistent, and passing the original exception as $previous preserves the full story for debugging. Since PHP 8, you can catch several types with |.
<?php
declare(strict_types=1);
class InsufficientBalanceException extends RuntimeException
{
public static function forAmount(int $balance, int $amount): self
{
return new self("Balance $balance is less than $amount");
}
}
class PaymentFailedException extends RuntimeException {}
function withdraw(int $balance, int $amount): int
{
if ($amount > $balance) {
throw InsufficientBalanceException::forAmount($balance, $amount);
}
return $balance - $amount;
}
function pay(int $balance, int $amount): int
{
try {
return withdraw($balance, $amount);
} catch (InsufficientBalanceException $e) {
// wrap the low-level exception, keep it as "previous"
throw new PaymentFailedException("Payment failed", 0, $e);
}
}
try {
pay(50000, 75000);
} catch (PaymentFailedException | InvalidArgumentException $e) {
echo get_class($e) . ": " . $e->getMessage() . "\n";
echo "Caused by: " . $e->getPrevious()->getMessage() . "\n";
}
Output:
PaymentFailedException: Payment failed
Caused by: Balance 50000 is less than 75000
A global exception handler
Uncaught exceptions should never show stack traces to users in production. Register a handler that logs the details and shows a friendly message (and set display_errors=Off in production php.ini):
<?php
declare(strict_types=1);
set_exception_handler(function (Throwable $e): void {
// In production: log details, show a generic message
error_log($e::class . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
echo "Something went wrong. Please try again later.\n";
});
function loadConfig(string $path): array
{
if (!is_file($path)) {
throw new RuntimeException("Config file not found: $path");
}
return require $path;
}
$config = loadConfig(__DIR__ . '/missing-config.php');
echo "This line is never reached\n";
Output:
Something went wrong. Please try again later.
The full details, including file and line, go to the error log, where only developers can see them.
Common Mistakes
- Swallowing exceptions. An empty
catchblock hides bugs. Catch specific exceptions and do something meaningful:
<?php
// BAD: the error disappears silently
try {
$data = json_decode('{invalid json', flags: JSON_THROW_ON_ERROR);
} catch (Exception $e) {
// nothing here...
}
// GOOD: catch the specific exception and react to it
try {
$data = json_decode('{invalid json', flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo "Invalid JSON: " . $e->getMessage() . "\n";
$data = [];
}
var_dump($data);
Output:
Invalid JSON: Syntax error
array(0) {
}
- Catching
Exceptionand expecting to catchTypeError. Engine errors extendError, notException. CatchThrowableonly at the top level. - File name and class name mismatch.
src/billing/invoice.phpworks on macOS but fails on a Linux server. Match case exactly. - Forgetting
composer dump-autoloadafter editing theautoloadsection. - Using exceptions for normal control flow. "User not found" during a search is often a normal result (
null), not an exception. - Committing
vendor/or editing files inside it. Your changes will be overwritten on the next install.
Exercise
Create a namespaced ValidationException that carries an array of field errors, throw it from a validation function, and print every error when catching it.
<?php
declare(strict_types=1);
namespace App\Validation;
final class ValidationException extends \InvalidArgumentException
{
/** @param array<string, string> $errors */
public function __construct(private array $errors)
{
parent::__construct(count($errors) . ' validation error(s)');
}
public function errors(): array
{
return $this->errors;
}
}
function validateUser(array $input): array
{
$errors = [];
if (trim($input['name'] ?? '') === '') {
$errors['name'] = 'Name is required';
}
if (!filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Email is invalid';
}
if ($errors) {
throw new ValidationException($errors);
}
return $input;
}
try {
validateUser(['name' => '', 'email' => 'budi@']);
} catch (ValidationException $e) {
echo $e->getMessage() . "\n";
foreach ($e->errors() as $field => $message) {
echo "- $field: $message\n";
}
}
echo ValidationException::class . "\n";
Output:
2 validation error(s)
- name: Name is required
- email: Email is invalid
App\Validation\ValidationException
Next, move the class into src/Validation/ValidationException.php in a Composer project and load it through the PSR-4 autoloader.
Summary
- Namespaces organize classes and avoid collisions; import with
useand alias withas. - Composer manages dependencies through
composer.jsonandcomposer.lock; never commitvendor/. - PSR-4 maps namespaces to folders, so one
require 'vendor/autoload.php'loads everything. - Throw exceptions for exceptional situations, catch specific types, use
finallyfor cleanup, and add a global handler for production.
In Part 9 we will connect to a database with PDO, using prepared statements, CRUD operations, and transactions.
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
Ruby on Rails Zero to Advanced #8: Forms, Strong Parameters & Full CRUD
Build a complete CRUD feature by hand with form_with and strong parameters, then compare it with what rails generate scaffold gives you for free.
Python Zero to Advanced #8: OOP - Classes, Inheritance, Dunder Methods & Dataclasses
Model real-world things with classes: attributes, methods, class vs instance data, properties, inheritance and super(), dunder methods like __repr__ and __eq__, and modern dataclasses.