Modern PHP
Namespaces, Composer/PSR-4 autoloading, enums, readonly properties, attributes, and the nullsafe operator.
Namespaces
A namespace groups related classes under a common prefix, avoiding name collisions between your code and third-party libraries that might otherwise declare a class with the same short name:
<?php
namespace App\Services;
class InvoiceGenerator {
public function generate(): string {
return "Invoice generated";
}
}
<?php
namespace App\Http\Controllers;
use App\Services\InvoiceGenerator; // import the fully-qualified class into this file's scope
class InvoiceController {
public function show(): string {
$generator = new InvoiceGenerator();
return $generator->generate();
}
}
Without the use import, you'd have to write out the fully-qualified name every time: new \App\Services\InvoiceGenerator().
Composer and PSR-4 autoloading
Composer is PHP's dependency manager — it downloads third-party packages, tracks exact versions in composer.lock, and, critically, generates an autoloader so you never write a manual require for every class file.
PSR-4 is the community standard mapping namespaces to directory structures: a namespace prefix maps to a base directory, and each \-separated segment after that maps to a subdirectory, with the class name itself matching the filename.
This very application follows exactly that convention — its composer.json maps the App\ namespace to the app/ directory:
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}
Which means a class declared as namespace App\Services; inside class InvoiceGenerator must live at app/Services/InvoiceGenerator.php — Composer's autoloader derives the file path directly from the fully-qualified class name, with no configuration needed per class:
App\Services\InvoiceGenerator -> app/Services/InvoiceGenerator.php
App\Http\Controllers\UserController -> app/Http/Controllers/UserController.php
<?php
require __DIR__ . '/vendor/autoload.php'; // the one require your entire app needs
$generator = new \App\Services\InvoiceGenerator(); // Composer finds and loads the file automatically
Enums (PHP 8.1+)
An enum represents a fixed, closed set of possible values as a genuine type — safer than the old convention of scattering string or int constants around a class.
Pure enums — just a set of named cases:
<?php
enum Status {
case Pending;
case Active;
case Suspended;
}
function describe(Status $status): string {
return match ($status) {
Status::Pending => "Waiting for approval",
Status::Active => "Currently active",
Status::Suspended => "Temporarily suspended",
};
}
echo describe(Status::Active); // Currently active
Backed enums — each case has an underlying scalar value (useful for storing in a database or sending over an API):
<?php
enum Role: string {
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
public function label(): string { // enums can have methods, just like classes
return match ($this) {
self::Admin => 'Administrator',
self::Editor => 'Content Editor',
self::Viewer => 'Read-only Viewer',
};
}
}
$role = Role::Admin;
echo $role->value; // admin — the underlying scalar
echo $role->label(); // Administrator
$fromDb = Role::from('editor'); // reconstruct an enum case from its stored value
echo $fromDb->label(); // Content Editor
Using match with an enum's cases (no default arm) gives you a compile-time-checked guarantee: if a new case is ever added to the enum and a match elsewhere forgets to handle it, PHP throws an UnhandledMatchError at runtime, surfacing the gap immediately instead of silently falling through.
Readonly properties (PHP 8.1+)
A readonly property can be assigned exactly once — typically inside the constructor — and any later assignment attempt throws an error. This is PHP's answer to modeling genuinely immutable value objects.
<?php
class Money {
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {}
public function add(Money $other): self {
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException("Currency mismatch");
}
return new self($this->amountInCents + $other->amountInCents, $this->currency); // return a NEW instance
}
}
$price = new Money(1999, 'USD');
// $price->amountInCents = 500; // Error: Cannot modify readonly property Money::$amountInCents
$total = $price->add(new Money(500, 'USD'));
echo $total->amountInCents; // 2499 — 'add' produced a new object rather than mutating $price
Attributes (PHP 8+)
Attributes are structured, machine-readable metadata attached directly to a class, method, or property using #[...] syntax — PHP's equivalent of Java annotations or C# attributes. Frameworks read them via reflection to drive behavior without requiring separate configuration files.
<?php
#[Attribute]
class Route {
public function __construct(
public readonly string $method,
public readonly string $path,
) {}
}
class UserController {
#[Route(method: 'GET', path: '/users/{id}')]
public function show(int $id): string {
return "Showing user $id";
}
}
Real frameworks (Symfony's routing, PHPUnit's test attributes, and increasingly parts of Laravel's ecosystem) use exactly this mechanism — reading attributes via ReflectionMethod::getAttributes() — to wire up behavior declaratively, right next to the code it describes.
The nullsafe operator (?->)
Before PHP 8, safely accessing a chain of properties/methods that might be null at any step required nested isset() checks. The nullsafe operator short-circuits the entire chain to null the moment any link is null, instead of throwing:
<?php
class Address {
public function __construct(public readonly ?string $city = null) {}
}
class User {
public function __construct(public readonly ?Address $address = null) {}
}
$user = new User(); // no address set
// Before PHP 8:
$city = $user->address !== null ? $user->address->city : null;
// PHP 8+:
$city = $user->address?->city; // null — short-circuits safely, no error
echo $city ?? "Unknown city"; // Unknown city
Common mistakes
- Manually
require-ing every class file instead of relying on Composer's PSR-4 autoloader — reinventing something the tooling already solves correctly. - Placing a class in the wrong directory relative to its namespace, breaking PSR-4 autoloading in a way that only surfaces as a confusing "class not found" error.
- Trying to reassign a
readonlyproperty outside its declaring class's constructor and being surprised by the resulting error. - Chaining
?->and assuming it protects the entire expression — it only short-circuits the specific chain of?->accesses, not unrelated code around it.
Interview questions
Q: How does PSR-4 autoloading actually locate a class file?
It maps a namespace prefix to a base directory (declared in composer.json), then derives the rest of the file path directly from the remaining namespace segments and the class name — so App\Services\InvoiceGenerator resolves to app/Services/InvoiceGenerator.php with zero per-class configuration, as long as the directory structure mirrors the namespace exactly.
Q: What problem do readonly properties solve?
They let you declare that a property can be set exactly once (normally in the constructor) and never modified afterward, enforced by the engine rather than by convention — which is exactly what's needed to model immutable value objects (money amounts, dates, identifiers) where accidental mutation elsewhere in a large codebase would be a real bug.