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
<?php

namespace App\Services;

class InvoiceGenerator {
    public function generate(): string {
        return "Invoice generated";
    }
}
PHP
<?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:

JSON
{
    "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:

Text
App\Services\InvoiceGenerator     ->     app/Services/InvoiceGenerator.php
App\Http\Controllers\UserController -> app/Http/Controllers/UserController.php
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
<?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
<?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
<?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
<?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
<?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 readonly property 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.