The PHP Book
This is my world, and welcome to it.
Foreword
I’ve spent a long time in and around PHP: writing it, breaking it, running static analysis over other people’s, and watching the language change underneath all three of those activities. This book exists because most PHP tutorials teach you to copy a snippet, not to understand why it works. You can get quite far that way. You can also spend years writing PHP without ever quite knowing what a reference is, why an array behaves differently from an object, or what strict_types actually buys you. I’d rather you didn’t.
This book is for people who want the second thing: to actually understand PHP, not just operate it by muscle memory. Maybe you’re new to programming entirely, and PHP is where you’re starting: that’s fine, it’s a perfectly reasonable place to start, and always has been, however much the loudest opinions on the internet suggest otherwise. Maybe you’ve written some PHP already, glued together from search results and half-remembered examples, and you want the ground underneath it to stop feeling slippery. Either way, I’ve tried to explain each idea once, properly, rather than leaving you to reconstruct it from context clues scattered across forum posts.
PHP has a reputation, and I won’t pretend otherwise: it earned a lot of it fairly, in an earlier era, with warnings you had to squint to read and comparisons that surprised even people who’d written the interpreter. That PHP mostly doesn’t exist anymore. The language you’re about to learn has real types, a serious error model, enums, first-class functions, and a community that ships static analysis tools most other ecosystems still envy. None of that erases the old reputation, and I’m not here to relitigate it. I’d just rather you judge the language in front of you than the one people are still telling stories about.
So: get a terminal open, get PHP installed, and let’s get started properly.
Introduction
PHP is a programming language built for the web, and it shows: every request to a PHP-powered page starts the program fresh, runs it, and throws the whole thing away, which turns out to be a remarkably robust way to build software that serves millions of people a day. That’s still its home turf: a huge share of the web runs on it, often without anyone thinking about it twice. But PHP has quietly grown well past that niche. It’s a capable command-line scripting language, a fine choice for small automation tools, and increasingly something people reach for outside a browser context entirely, with async runtimes and long-running processes now part of the conversation. This book covers PHP the language first, and treats “runs inside a web server” as one deployment option among several, not the only story worth telling.
This book assumes you’re comfortable opening a terminal and typing commands: cd into a directory, run a file, read an error message without panicking. It does not assume you’ve programmed before. If you have, you’ll recognize the shapes (variables, loops, functions) and can move a little faster through the early chapters, watching for where PHP’s version of a familiar idea has its own personality. If you haven’t, the early chapters build every idea from the ground up, and nothing later in the book assumes you skipped ahead.
The book is front-loaded with fundamentals on purpose. The first several chapters cover the pieces every PHP program is built from (variables, types, control flow, functions) and they lean on a small worked example, a number-guessing game, to make the ideas concrete before naming them formally. From there, the projects get progressively bigger: a command-line tool with real file handling and error recovery, and eventually a small web application built from first principles, no framework standing between you and what’s actually happening. By the time you reach those chapters, you’ll have accumulated the vocabulary and instincts to read the code without a running translation happening in your head.
How you read it is up to you. If you’re new to PHP or to programming generally, read it in order: each chapter assumes the ones before it, and the later projects are genuinely easier to follow if you’ve built the habits earlier chapters establish. If you already know your way around a codebase and just need PHP’s specifics, how its types behave, how its objects differ from its arrays, what its newer syntax looks like, treat it as a reference and jump straight to the chapter you need. Either way works. The chapters try to stand on their own where they reasonably can, with links back to earlier material whenever they lean on something you’d otherwise have to take on faith.
One last thing before you start: install PHP. Chapter 1 walks through it, but there’s no substitute for having the interpreter open next to this book and actually running the examples as you go. Reading about a language and writing in it are different skills, and this book is much more useful if you let it teach you the second one.
Getting Started
Let’s get you writing PHP.
We’ll start by installing PHP itself (no framework, no build tool, nothing fancy, just the interpreter) and then write the smallest program that could possibly prove it works.
None of this requires a web server. PHP started life on the web, and most PHP code still ends up serving HTTP requests eventually, but you don’t need Apache, nginx, or a browser to learn the language. Everything in this chapter runs from your terminal. That’s on purpose: a terminal gives you instant feedback, and instant feedback is what makes a language click.
A quick note on versions before we start: this book assumes PHP 8.1 or newer. PHP has been actively developed for thirty years, and older tutorials floating around the internet will show you code that either no longer works or, worse, still works but that nobody would write today. We’ll stick to modern PHP throughout: it’s a genuinely nicer language than its reputation suggests, and there’s no reason to learn it as it was in 2010.
Installation
Checking whether you already have PHP
Open a terminal and try this:
$ php -v
PHP 8.3.6 (cli) (built: ...) (NTS)
If you get a version number, and it starts with an 8, you’re basically done. Skip ahead to the next section. If it starts with a 7 or lower, or the command isn’t found at all, read on.
Installing PHP
macOS. The system used to ship an ancient PHP for internal use; recent macOS versions ship none at all. Either way, install a current one with Homebrew:
$ brew install php
Linux. Your distribution’s package manager has it, though the version lagging behind can be a year or two out of date depending on the release. On Ubuntu or Debian, the Ondřej Surý PPA keeps up with new releases faster than the default repositories:
$ sudo apt install php-cli
Windows. Grab the “Non Thread Safe” zip from windows.php.net, unzip it somewhere sane like C:\php, and add that folder to your PATH. If you’d rather not manage this by hand, Laragon or WampServer bundle PHP with a friendlier installer.
Anywhere, with Docker. If you don’t want to install anything system-wide:
$ docker run --rm -it php:8.3-cli bash
This drops you into a shell with PHP ready to go. It’s a fine way to follow along with this book without touching your machine’s configuration at all.
Verifying the install
Run php -v again. You should see a version number this time. While you’re there, run this too: it lists which optional pieces (called extensions) are compiled into your PHP:
$ php -m
Don’t worry about the list. We’ll come back to extensions when we actually need one.
One last thing worth knowing: PHP has both a command-line version (php-cli, what you just installed) and versions meant to run inside a web server (php-fpm, mod_php). They’re the same language underneath, but this book only needs the CLI one: the version that runs scripts directly from your terminal, the way Python or Ruby would.
Hello, World!
Create a file called hello.php, anywhere you like:
<?php
echo "Hello, world!\n";
Then run it:
$ php hello.php
Hello, world!
That’s it. That’s the whole program. Let’s slow down and look at what’s actually in those two lines, because every single one of them matters.
<?php
PHP is, at heart, a templating language that grew a full programming language inside it. That opening tag is a leftover (and a permanent feature) of that history: everything outside <?php ... ?> tags is sent straight to output, untouched, as plain text or HTML. Everything inside is parsed as PHP code.
In a file that’s pure PHP, like ours, you’ll almost always see just the opening tag and nothing else: no closing ?>, and definitely nothing before it. Skipping the closing tag at the end of a file is a deliberate convention, not an oversight: it’s impossible to accidentally leak a stray blank line or space after a tag that was never closed. You’ll thank this convention the first time a stray newline after ?> breaks a redirect three files away from the one you edited.
echo
echo prints its argument. It’s not a function: no parentheses required, though echo("...") also works because PHP is relaxed about it. You’ll see echo constantly; it’s the workhorse of PHP output, alongside print (nearly identical, but a real expression that returns 1) and printf (for when you need formatting).
The string, and that \n
"Hello, world!\n" is a double-quoted string. Inside double quotes, PHP interprets escape sequences like \n (newline) and, as we’ll see very soon, variables too. Single-quoted strings ('Hello, world!') don’t do either of those; they’re closer to “what you typed is what you get.” Neither is more “correct”; you’ll pick between them constantly, usually based on whether you need PHP to look inside the string or leave it alone.
The semicolon
PHP statements end with ;. Forget one and PHP will, in typical fashion, wait until the next line to complain, pointing at code that was perfectly innocent. When you get a baffling parse error, the first thing worth checking is always the line above the one PHP is blaming.
Running it
php hello.php runs the interpreter directly against your file: no compile step, no build artifact left behind. This is the single biggest difference in feel between PHP and a compiled language: you edit, you run, you see the result, immediately. We’ll lean on that tight loop constantly throughout this book.
There’s also a REPL, if you want to try one-liners without creating a file:
$ php -a
Interactive shell
php > echo "Hello, world!\n";
Hello, world!
php > exit
Handy for quick experiments. Not something you’ll build real programs in, but neither did anyone building real programs in a REPL for any other language.
Programming a Guessing Game
Let’s build something: not a “hello world,” but an actual small program, with input, output, a loop, and a decision to make on every pass through it. We’ll write a number-guessing game: the computer picks a secret number, you guess, and it tells you whether to go higher or lower until you get it.
You won’t understand every keyword we use here yet, and that’s fine: that’s the point. Chapter 3 will go back and explain each piece properly. For now, just type along, run it, and get a feel for what PHP code looks like in motion.
Setting up
Create a file called guessing_game.php:
<?php
echo "Guess the number!\n";
echo "Please input your guess.\n";
$guess = trim(fgets(STDIN));
echo "You guessed: {$guess}\n";
Run it:
$ php guessing_game.php
Guess the number!
Please input your guess.
42
You guessed: 42
Two new things worth pausing on.
fgets(STDIN) reads one line of input from the terminal, keyboard and all. STDIN is a built-in constant pointing at standard input, the same channel every command-line tool reads from. It hands you back a string that includes the trailing newline you pressed Enter with, which is almost never what you want, so we immediately strip it with trim().
$guess is a variable: in PHP, every variable name starts with $. No declaration keyword, no type up front; you just assign to it and it exists. We’ll spend real time on this in the next chapter.
And "You guessed: {$guess}\n" is string interpolation: inside a double-quoted string, {$guess} is replaced with the variable’s value. The curly braces aren’t strictly required for a simple variable like this one ("You guessed: $guess\n" works too), but they remove any ambiguity about where the variable name ends, which matters the moment you’re interpolating something like {$user->name}.
Generating a secret number
PHP’s built-in random_int() gives us a cryptographically solid random integer in a range: overkill for a guessing game, but it’s also the correct default to reach for whenever you need randomness, so we may as well build the habit now:
<?php
$secretNumber = random_int(1, 100);
echo "Guess the number!\n";
echo "The secret number is between 1 and 100.\n";
echo "Please input your guess.\n";
$guess = trim(fgets(STDIN));
echo "You guessed: {$guess}\n";
Try running this a few times. Notice the secret number changes, but you can’t tell, because we’re not comparing anything yet. Let’s fix that.
Comparing the guess to the secret number
<?php
$secretNumber = random_int(1, 100);
echo "Guess the number!\n";
echo "Please input your guess.\n";
$guess = (int) trim(fgets(STDIN));
if ($guess < $secretNumber) {
echo "Too small!\n";
} elseif ($guess > $secretNumber) {
echo "Too big!\n";
} else {
echo "You win!\n";
}
One quiet but important change: (int) in front of trim(fgets(STDIN)). Everything read from the keyboard arrives as a string: even if the user typed 42, what we actually get is the three characters "42", not the number 42. Comparing a string to an integer with < and > mostly does the right thing in PHP thanks to a feature called type juggling, but “mostly” is exactly the kind of word that should make you nervous. Casting explicitly with (int) converts the string to a real integer, on purpose, so there’s no ambiguity about what we’re comparing. We’ll dig into type juggling (and why to be deliberate about it) in the next chapter.
Looping until the right guess
Right now the program checks one guess and quits, win or lose. Let’s let the player keep trying:
<?php
$secretNumber = random_int(1, 100);
echo "Guess the number!\n";
while (true) {
echo "Please input your guess.\n";
$guess = (int) trim(fgets(STDIN));
if ($guess < $secretNumber) {
echo "Too small!\n";
} elseif ($guess > $secretNumber) {
echo "Too big!\n";
} else {
echo "You win!\n";
break;
}
}
while (true) loops forever, on purpose: this is the standard PHP idiom for “keep going until something inside the loop tells you to stop.” That something is break, which exits the loop immediately. Put the break only in the winning branch, and the loop naturally keeps asking until the player gets it right.
Handling bad input
There’s still a rough edge: if someone types banana instead of a number, (int) "banana" quietly becomes 0, no error, no warning, just a wrong answer treated as a real guess. Whether that’s acceptable depends on your program, but let’s handle it properly, the way you would in real code, using is_numeric() to check before we trust the input:
<?php
$secretNumber = random_int(1, 100);
echo "Guess the number!\n";
while (true) {
echo "Please input your guess.\n";
$input = trim(fgets(STDIN));
if (!is_numeric($input)) {
echo "That doesn't look like a number, try again.\n";
continue;
}
$guess = (int) $input;
if ($guess < $secretNumber) {
echo "Too small!\n";
} elseif ($guess > $secretNumber) {
echo "Too big!\n";
} else {
echo "You win!\n";
break;
}
}
continue is break’s sibling: instead of exiting the loop, it jumps straight back to the top for the next iteration, skipping everything below it. Here, a bad guess just asks again, no crash, no wrong-guess penalty for a typo.
Where we’ve landed
Around thirty lines, and it already has input, output, a loop, a conditional, a bit of type conversion, and some input validation, which is a fair chunk of what real programs are made of, guessing games included. Keep this file around; we’ll revisit small CLI programs like this one throughout the book, and by Chapter 14 you’ll be structuring something a good deal more serious than a guessing game.
Common Programming Concepts
This chapter covers the concepts you’ll find in nearly every programming language, but told through PHP’s particular way of doing things: variables and constants, the types PHP works with, how functions are shaped, how to leave notes for your future self, and how to make decisions and repeat work.
None of this will be conceptually new if you’ve programmed before: every language has variables and if statements. What’s worth paying attention to is where PHP’s version of these ideas has its own personality: variables that don’t need declaring, a type system that’s stricter than its reputation once you ask it to be, and a match expression that’s a genuine pleasure to use once you’re tired of switch.
If you’re brand new to programming altogether, this chapter is also a fine place to build your very first mental model of “what is a program, actually.” Take your time with it. Everything later in the book leans on these five sections.
Variables, Constants, and Mutability
Variables
Every PHP variable starts with a dollar sign, and that’s genuinely most of the syntax you need to know:
<?php
$greeting = "Hello";
echo $greeting;
$greeting = "Goodbye";
echo $greeting;
No let, no var (well, there is var, but it’s a fossil from PHP 4 that only means something inside a class, and you won’t use it). You just assign, and the variable exists from that point on. There’s no separate declaration step to forget.
That also means every variable is mutable by default: reassigning $greeting above isn’t a special operation requiring permission, it’s just… assignment, again. If you’re coming from a language that makes you opt into mutability, this will feel like the opposite default. PHP’s position is that mutability is the normal case and immutability is the thing you build deliberately, usually with objects (more on that once we reach classes).
Naming
Variable names are case-sensitive, must start with a letter or underscore, and by convention use camelCase:
<?php
$userName = "damien";
$total_price = 42.50; // valid, but not idiomatic PHP
Both lines above work. Only the first one is what you’ll see in modern PHP code and in the standards (PSR-12) most projects follow. Function and class names have their own conventions we’ll get to later. PHP’s ecosystem cares more about consistency within a codebase than about any one style being objectively correct, but camelCase for variables is about as close to universal as PHP conventions get.
Constants
When a value genuinely should never change during the program’s execution (a configuration value, a mathematical constant, an API base URL), reach for a real constant instead of a variable you simply promise not to touch:
<?php
define('MAX_RETRIES', 3);
echo MAX_RETRIES;
const APP_NAME = 'GuessingGame';
echo APP_NAME;
Two ways to write one, and both are common in the wild. define() is a function call, evaluated at runtime, and works anywhere. const is a language construct, resolved at compile time, and (this is the part that trips people up) can only be used at the top level of a file or inside a class; you can’t const something inside an if block or a function body. Outside of a class, prefer const; it’s slightly faster and reads more like what it is.
Notice constants have no $: that’s deliberate, so you can tell at a glance, anywhere in a file, that MAX_RETRIES isn’t going to change out from under you, unlike $maxRetries, which anyone downstream is free to reassign.
A word about “mutability” versus what PHP actually does
If you’ve read about other languages that make a big deal out of ownership or borrowing, you might expect PHP’s story here to be more complicated than “just assign to it.” It genuinely isn’t, at this level, but PHP does have its own, much gentler version of “who owns this data,” which shows up once you start passing arrays and objects into functions rather than just printing strings. We’ll get there in Working with Variables and References, once you’ve seen enough of the language for it to matter.
Data Types
PHP will let you write an entire program without ever mentioning a single type. It will also, if you ask it to, hold you to your types as strictly as any statically-typed language would refuse a mismatch. Both of these are true at once, and understanding why is most of what this section is about.
Scalar types
Four types hold a single value each:
<?php
$age = 41; // int
$price = 19.99; // float
$name = "Damien"; // string
$isReady = true; // bool
Check what you’re holding with gettype() or, more usefully while debugging, var_dump():
<?php
var_dump($age);
// int(41)
var_dump($price);
// float(19.99)
var_dump() will become one of your most-used tools. Get comfortable with it early: it tells you not just the value but the type, which echo never will.
Compound types
Two types hold collections of other things.
Arrays are PHP’s do-everything data structure: list, dictionary, stack, queue, all the same underlying type wearing different hats:
<?php
$fruits = ["apple", "banana", "cherry"]; // indexed
$prices = ["apple" => 0.5, "banana" => 0.3]; // associative
We’ll spend all of Chapter 8 on arrays, because they deserve it: there is no PHP program of any size that doesn’t lean on them constantly.
Objects are instances of classes, PHP’s building block for bundling data with the behavior that operates on it. We’re not there yet (Chapter 5 is where objects properly start), but you’ll see the odd one in passing before then.
Special types
null represents “no value at all”: not zero, not an empty string, genuinely nothing:
<?php
$middleName = null;
You’ll meet null constantly, usually in the form of “did this function find anything, or not.” PHP 8.1 also gave null some real teeth with enums and the nullsafe operator (?->), both coming in Chapter 6.
Type juggling, and how to stop worrying about it
Here’s the thing PHP is famous for, fairly or not: it will convert between types automatically when an operator demands it.
<?php
var_dump("5" + 3); // int(8)
var_dump("5" . 3); // string(2) "53"
var_dump(0 == "abc"); // false, as of PHP 8 (this used to be true!)
+ expects numbers, so the string "5" gets converted; . (string concatenation) expects strings, so the integer 3 gets converted the other way. This is type juggling, and older PHP tutorials will tell you horror stories about it, mostly because loose comparison (==) used to have some genuinely surprising rules before PHP 8 tightened them up considerably.
Two habits keep this from ever biting you:
Prefer === over ==. Strict comparison checks type and value, with no conversion: 0 === "abc" is simply false, no asterisk needed. Reach for loose == only when you specifically want the conversion.
Turn on strict types. Put this as the very first statement in a file, right after <?php:
<?php
declare(strict_types=1);
function double(int $n): int {
return $n * 2;
}
double("4"); // TypeError: no silent conversion here
Without declare(strict_types=1), PHP will happily convert "4" to 4 for you when it’s passed into a typed parameter. With it, that same call throws a TypeError instead. Modern PHP code almost always turns this on: it turns “PHP quietly guessed what you meant” into “PHP told you exactly what went wrong,” which is a much better bug report to receive.
We’ll use type declarations (on parameters, return values, and eventually properties) throughout this book. They’re optional in PHP, but treat them as the default, not the exception.
Functions
You’ve already used a handful of PHP’s built-in functions: trim(), echo (which, as noted earlier, technically isn’t one), random_int(). Let’s write your own.
<?php
function greet($name) {
return "Hello, {$name}!\n";
}
echo greet("Damien");
function, a name, parentheses for parameters, and a body in braces: that’s the whole shape. PHP function names are, like variables, case-insensitive at the call site (please don’t rely on that) and conventionally camelCase.
Parameters and types
Add type declarations to parameters the same way you saw in the previous section, and give the function a return type too:
<?php
function greet(string $name): void {
echo "Hello, {$name}!\n";
}
: void says this function doesn’t return a value: it’s called purely for its side effect (printing, here). Every function that does hand something back should declare what:
<?php
function add(int $a, int $b): int {
return $a + $b;
}
$sum = add(2, 3);
Type every parameter and every return value, on every function you write, from here on. It costs you a few extra keystrokes and saves you from an entire category of bugs where a function silently receives, or returns, something you didn’t expect. Combined with declare(strict_types=1) from the previous section, this turns PHP from “dynamically typed and a little too forgiving about it” into something that will actually stop you at the door when you pass the wrong thing.
Default values
Parameters can have defaults, which makes them optional at the call site:
<?php
function greet(string $name, string $greeting = "Hello"): string {
return "{$greeting}, {$name}!\n";
}
echo greet("Damien"); // Hello, Damien!
echo greet("Damien", "Bonjour"); // Bonjour, Damien!
Parameters with defaults must come after parameters without them: PHP reads arguments left to right, so it needs the required ones settled first.
Named arguments
Speaking of argument order: PHP lets you pass arguments by name instead of position, which is a genuine quality-of-life feature once a function has more than two or three parameters:
<?php
echo greet(name: "Damien", greeting: "Bonjour");
echo greet(greeting: "Bonjour", name: "Damien"); // order no longer matters
This is especially welcome with functions that have several optional parameters: you can skip straight to the one you actually want to override, instead of passing every default in between just to reach it positionally.
Return values are expressions, not just for void functions
return immediately exits the function with a value:
<?php
function classify(int $n): string {
if ($n < 0) {
return "negative";
}
if ($n === 0) {
return "zero";
}
return "positive";
}
There’s no implicit “last expression is the return value” the way some languages work: PHP always wants an explicit return. Leave it off, and a function returns null by default, silently. That’s usually a bug, not a choice, which is exactly why declaring : void on functions that truly return nothing is worth the habit: it lets PHP (and any static analysis tool checking your code) flag it if you accidentally do return something from one.
Functions as values
One more thing worth knowing early, even though we won’t use it in earnest until Chapter 15: functions in PHP are values too. You can hold one in a variable and call it:
<?php
$operation = 'add';
echo $operation(2, 3); // calls add(2, 3), if add() is defined above
And PHP has genuine anonymous functions, closures, for when you need to pass behavior around without giving it a name at all:
<?php
$double = function (int $n): int {
return $n * 2;
};
echo $double(21); // 42
File that away for now. It’ll matter a great deal later, once we start passing small pieces of behavior into array functions and generators.
Comments
PHP gives you three ways to write a comment, which is one more than most languages bother with, for reasons rooted in its templating-language ancestry.
<?php
// A single-line comment.
# Also a single-line comment, same effect, different heritage
// (this style is borrowed from shell scripts; you'll see it far less often).
/*
* A multi-line comment,
* for when one line isn't enough.
*/
In practice, // dominates for everyday comments, and /* ... */ shows up for the longer, more structured kind: most commonly as a docblock sitting right above a function or class:
<?php
/**
* Calculates compound interest.
*
* @param float $principal Starting amount
* @param float $rate Annual interest rate, as a decimal (e.g. 0.05 for 5%)
* @param int $years Number of years to compound
* @return float The final amount after compounding
*/
function compoundInterest(float $principal, float $rate, int $years): float {
return $principal * (1 + $rate) ** $years;
}
That /** opener (two asterisks, not one) marks it as a docblock (a convention, not a language feature) and tools like your editor, PHPStan, and documentation generators all know to read @param and @return tags out of it. We’ll lean on docblocks properly once we hit generics-adjacent territory in Chapter 11, where PHP’s type system needs a little help from comments to say things the language itself can’t express yet.
What’s worth commenting
The honest answer is: less than you’d think. A well-named function and well-typed parameters explain themselves; a comment repeating what the code already says just gives you two places to keep in sync, and only one of them the compiler checks.
<?php
// Bad: says what, which the code already says
// Increment the counter by one
$counter++;
// Good: says why, which the code can't say on its own
// Retry once more here: the upstream API is flaky on cold start
$retries++;
Comment the why, not the what. A comment explaining a workaround, a non-obvious constraint, or a decision that would look wrong without context is worth its weight. A comment translating code into English, line by line, usually isn’t, and it’s one more thing to go stale the next time someone edits the code without updating the comment above it.
Control Flow
You’ve already seen if, while, break, and continue in the guessing game. This section makes them official and fills in the rest.
if / elseif / else
<?php
$temperature = 18;
if ($temperature > 30) {
echo "Hot.\n";
} elseif ($temperature > 15) {
echo "Pleasant.\n";
} else {
echo "Bring a jacket.\n";
}
Note it’s elseif, one word (else if, two words, also works), but only elseif reads as a single token to PHP, so it’s the convention worth adopting. The condition doesn’t need to be a boolean; PHP will convert whatever you hand it: 0, "", null, and [] are all “falsy,” everything else is “truthy,” but relying on that too heavily is exactly the kind of type-juggling ambiguity Chapter 3.2 warned you about. Prefer an explicit comparison when it isn’t already obviously a boolean.
An if/elseif chain like this one is at its best when each branch tests something genuinely different, the way $temperature > 30 and $temperature > 15 do above. Once you catch yourself writing several branches that all compare the same value against a list of possibilities, that repetition is a sign to reach for the next tool instead.
match
PHP 8 added match, and once you’ve used it, switch starts to feel like a relic:
<?php
$httpStatus = 404;
$message = match (true) {
$httpStatus >= 200 && $httpStatus < 300 => "Success",
$httpStatus >= 400 && $httpStatus < 500 => "Client error",
$httpStatus >= 500 => "Server error",
default => "Unknown",
};
echo $message; // Client error
Two things make match a real upgrade over switch: it’s an expression (it produces a value you can assign, as above, rather than a statement you branch inside of), and its comparisons are strict (===), so there’s no accidental type juggling sneaking a wrong branch through. It also has no fallthrough to accidentally forget a break on. We’ll give match a full chapter of its own once we pair it with enums in Chapter 6, where the two turn out to be made for each other.
Loops
while runs as long as its condition holds, checked before each pass:
<?php
$count = 3;
while ($count > 0) {
echo "{$count}...\n";
$count--;
}
echo "Go!\n";
do...while is the same idea, but checks after the first pass, guaranteeing the body runs at least once:
<?php
do {
echo "This runs once even if the condition is already false.\n";
} while (false);
for is the classic three-part loop, most at home when you need an index:
<?php
for ($i = 0; $i < 5; $i++) {
echo "{$i}\n";
}
foreach is the one you’ll reach for constantly once arrays enter the picture in Chapter 8: it walks a collection directly, no index bookkeeping required:
<?php
$fruits = ["apple", "banana", "cherry"];
foreach ($fruits as $fruit) {
echo "{$fruit}\n";
}
$prices = ["apple" => 0.5, "banana" => 0.3];
foreach ($prices as $name => $price) {
echo "{$name}: \${$price}\n";
}
That second form, as $name => $price, pulls out both the key and the value in one go, and it’s used so often in real PHP code that it’s worth committing to memory right now.
break and continue, one more time
You met both in the guessing game: break exits a loop immediately, continue skips to the next iteration. Both accept an optional number (break 2 exits two levels of nested loop at once), but reach for that only when it genuinely reads clearer than restructuring the loop; nested break levels are exactly the kind of thing that’s obvious while you’re writing it and baffling a month later.
<?php
foreach ([1, 2, 3, 4, 5] as $n) {
if ($n === 3) {
continue; // skip 3, keep going
}
if ($n === 5) {
break; // stop entirely once we hit 5
}
echo "{$n}\n";
}
// prints 1, 2, 4
That’s the toolkit: branch with if or match, repeat with while, do...while, for, or foreach, and steer loops precisely with break and continue. Everything from here on in the book is built out of these same handful of pieces, arranged in more interesting shapes.
Working with Variables and References
You’ve been assigning variables since the guessing game in Chapter 2, and by now $x = $y looks like the most unremarkable line of code imaginable. It mostly is, but “mostly” is doing some quiet work in that sentence, and this chapter is about the part it’s hiding.
What happens when you assign an array to a new variable, and then modify the copy? What happens when you do the same thing with an object? These two questions have different answers in PHP, and the difference is not a minor implementation detail: it’s one of the most common sources of confusion for people arriving from other languages, and one of the most common sources of genuinely strange bugs for people who never had it explained. Arrays behave as if each assignment made a brand-new, independent copy. Objects behave as if every variable holding one is just another name for the same underlying thing. Get this backwards in your head, and you will eventually write a function that “doesn’t work” for reasons that look like nothing, right up until you understand this chapter.
We’ll also look at PHP’s explicit reference syntax: the & that lets you opt into shared-variable behavior on purpose, whether that’s two variables sharing one array or a function that modifies its caller’s variable directly. References are a sharp tool: genuinely useful in specific situations, and a reliable source of confusing code when reached for out of habit. You’ll learn where they earn their keep and where they don’t.
Finally, we’ll close out with two smaller but related ideas: variable scope (what a function can and can’t see of the code around it) and a brief, honest look at how PHP reclaims memory it’s no longer using. Neither needs deep study to use PHP well, but both come up often enough that you should recognize the vocabulary when you meet it again later in the book.
How PHP Manages Values: Copy-on-Write
Assign one array to another variable, and PHP behaves as though it made you a completely independent copy:
<?php
$original = [1, 2, 3];
$copy = $original;
$copy[] = 4;
var_dump($original); // array(3) { [0]=> int(1) [1]=> int(2) [2]=> int(3) }
var_dump($copy); // array(4) { [0]=> int(1) [1]=> int(2) [2]=> int(3) [3]=> int(4) }
$original is untouched. Modifying $copy had no effect on it whatsoever: exactly as if $copy = $original had walked through the array and duplicated every element into fresh memory on the spot. That’s the mental model you should carry around, and for most day-to-day PHP it’s all you need.
But it doesn’t actually copy on the spot
Here’s the part that’s worth knowing even though it rarely changes how you write code: PHP doesn’t really duplicate the array the instant you write $copy = $original. That would be wasteful: plenty of arrays get assigned around and never modified at all, so copying eagerly would mean paying a cost for work that’s frequently never needed. Instead, PHP uses a strategy called copy-on-write. The assignment just makes $copy and $original point at the same underlying array data, and PHP quietly counts how many variables are pointing at it. Only the moment you actually modify one of them (as $copy[] = 4 does above) does PHP step in, make a real, separate copy first, and apply your change to that copy alone. Read from both variables without changing either, and they’ll happily keep sharing the same underlying data behind the scenes:
<?php
$original = ["apple", "banana"];
$copy = $original; // no copying has happened yet, both point at the same data
foreach ($copy as $fruit) {
echo $fruit . "\n"; // just reading, still sharing
}
$copy[] = "cherry"; // *now* PHP actually duplicates the array
You can’t observe this happening from inside your program: there’s no function call, no visible delay, nothing that behaves differently depending on whether the copy has “really” happened yet. It’s purely an optimization the engine performs for you. But the vocabulary matters, because you’ll see “copy-on-write” mentioned in PHP performance discussions, RFC text, and the odd profiler output, and it helps to know it isn’t some exotic caching layer. It’s just PHP being lazy about a copy it was always going to make available to you, semantically, whether or not it does the work up front.
Why this matters for functions
This is where copy-on-write stops being trivia and starts affecting how you write code. Pass an array into a function, and the function receives what behaves like its own independent copy:
<?php
declare(strict_types=1);
function addTax(array $prices): array
{
foreach ($prices as $key => $price) {
$prices[$key] = round($price * 1.2, 2);
}
return $prices;
}
$cart = ["book" => 10.00, "pen" => 2.00];
$withTax = addTax($cart);
var_dump($cart); // unchanged: book => 10.00, pen => 2.00
var_dump($withTax); // book => 12.00, pen => 2.40
addTax() modifies $prices freely inside the function, and none of that leaks back out to $cart. This is usually exactly what you want: a function that takes an array shouldn’t be able to reach back out and rewrite data the caller is still holding onto, unless you’ve explicitly asked for that. If you ever do want a function to modify the caller’s array directly, that’s not something copy-on-write gives you: it’s what references are for, which is the whole subject of the next section.
One thing worth flagging now, because the contrast is coming: this entire section has been about arrays. Objects play by a different set of rules: assigning one variable’s object to another does not give you an independent copy, in any sense, lazy or otherwise. That distinction is important enough to earn its own careful treatment, which is where we’re headed next.
Passing by Value vs. by Reference
The previous section showed you PHP’s default: assign an array to a new variable, modify the new one, and the original is untouched. That default is called passing (or assigning) by value, and it’s what happens everywhere in PHP unless you explicitly ask for something else. This section covers how to ask for something else. More importantly, it covers the one place where PHP quietly gives you “something else” whether you asked for it or not: objects.
Explicit references with &
PHP lets you make two variables refer to the same underlying value on purpose, using &:
<?php
$a = 10;
$b = &$a; // $b is now an alias for $a, not a copy of its value
$b = 20;
echo $a; // 20
After $b = &$a, there’s no meaningful sense in which $a and $b are two separate variables holding equal values: they’re two names for the same storage. Change either one, and you’ve changed both, because there was only ever one thing to change.
You can pass this behavior into a function too, by declaring the parameter with &:
<?php
declare(strict_types=1);
function addTax(array &$prices): void
{
foreach ($prices as $key => $price) {
$prices[$key] = round($price * 1.2, 2);
}
}
$cart = ["book" => 10.00, "pen" => 2.00];
addTax($cart);
var_dump($cart); // book => 12.00, pen => 2.40, modified in place
Compare this to the addTax() from the previous section: same body, but the & before $prices changes everything about how the caller experiences it. Without &, the function received a value it could freely mutate without consequence to the caller. With &, $prices inside the function is $cart outside it: there’s no copy at all, not even a lazy one. This is genuinely useful when a function’s whole job is to modify something in place: think sort(), which is a real built-in PHP function that works exactly this way, rearranging your array through a reference rather than handing you back a new one.
It’s also easy to overuse. A function signature with & in it is quietly changing the contract of the function from “give me data, get data back” to “let me reach into your variable and change it,” and that’s a bigger promise than it looks like on the page. Reach for it when in-place mutation is genuinely the point (sorting, filling a buffer, that kind of thing) and prefer an ordinary return value everywhere else. Code that returns its result is easier to read, easier to test, and easier to trust at a glance; code sprinkled with & parameters requires the reader to go check every call site to know what might have changed.
Arrays copy, objects don’t
Here’s the gotcha this whole chapter has been building toward, and it’s worth reading slowly, because it trips up almost everyone the first time they meet it.
You already know arrays copy by value: copy-on-write, but functionally a copy. Objects don’t. When you assign an object to a variable, pass it into a function, or store it in an array, PHP never duplicates the object itself. Every variable that ends up “holding” that object is really just holding a handle to the one instance living in memory. Copy the variable all you like: you’re copying the handle, not the thing it points to.
<?php
declare(strict_types=1);
class Cart
{
public array $items = [];
}
$cartA = new Cart();
$cartA->items[] = "book";
$cartB = $cartA; // NOT a copy, $cartB points at the same Cart instance
$cartB->items[] = "pen";
var_dump($cartA->items); // ["book", "pen"], both items show up here too
var_dump($cartB->items); // ["book", "pen"]
$cartB = $cartA looks exactly like $copy = $original did with arrays. It behaves nothing like it. There is only one Cart object here; $cartA and $cartB are two labels stuck on the same box. Modify the box through either label, and anyone holding the other label sees the change immediately, because there’s nothing else to see: it’s the same object.
This is the single most common source of “why did my function change something it wasn’t supposed to touch” bugs in beginner PHP code, and it runs in exactly the opposite direction of the array confusion: people expect objects to copy like arrays do, get burned once, and then overcorrect by assuming everything aliases like objects do. Neither assumption is right. The rule is simple once it’s explicit: arrays copy, objects alias. Passing an object into a function never protects the caller’s data the way passing an array does: the function receives a handle to the very same instance, and anything it does through that handle is visible the moment the function returns, no & required.
<?php
declare(strict_types=1);
function addItem(Cart $cart, string $item): void
{
$cart->items[] = $item; // this mutates the caller's actual Cart
}
$cart = new Cart();
addItem($cart, "notebook");
var_dump($cart->items); // ["notebook"], visible outside the function, no & needed
No & appears anywhere in addItem()’s signature, and none is needed. Objects are always “passed by handle” (sometimes described loosely as “passed by reference,” though that’s not quite the precise PHP term), since you can reassign $cart inside the function to point it at a different object entirely without affecting the caller’s variable. What you can’t do is mutate the object it points to without that mutation being visible everywhere else that same object is referenced.
clone, the escape hatch
Sometimes you genuinely want an independent copy of an object: a second Cart with the same starting items that can then diverge from the original. That’s what clone is for:
<?php
declare(strict_types=1);
$cartA = new Cart();
$cartA->items[] = "book";
$cartB = clone $cartA; // a genuine, separate copy
$cartB->items[] = "pen";
var_dump($cartA->items); // ["book"], untouched
var_dump($cartB->items); // ["book", "pen"]
clone creates a new object with the same property values as the original, and from that point on the two instances are fully independent, exactly the behavior you might have mistakenly expected from plain assignment. One caveat worth flagging now and revisiting later: clone copies properties one level deep. If one of Cart’s properties were itself an object rather than a plain array, the clone and the original would still share that nested object, handle and all, unless you do something about it. PHP gives classes a __clone() magic method for exactly this situation, which we’ll cover once we’ve spent more time with classes in general, starting in Chapter 5.
Variable Scope and Garbage Collection
Functions have their own scope
Every function in PHP gets its own private set of variables, completely separate from whatever’s happening outside it. A variable defined in one function simply doesn’t exist as far as another function, or the top-level script, is concerned:
<?php
declare(strict_types=1);
function greet(): void
{
$message = "Hello from inside greet()";
echo $message . "\n";
}
greet();
echo $message ?? "no such variable out here\n";
$message inside greet() and any $message you might have floating around outside it are entirely unrelated, even though they share a name. This is called local scope, and it’s the sane default: without it, every variable name in every function would be competing for the same shared space, and calling a function you didn’t write yourself would be a small act of faith that it hadn’t quietly stomped on one of your variables.
global, and why you’ll rarely reach for it
PHP does have a way to let a function read and write a variable from the outer, top-level scope: the global keyword.
<?php
declare(strict_types=1);
$counter = 0;
function increment(): void
{
global $counter;
$counter++;
}
increment();
increment();
echo $counter; // 2
It works. It’s also almost never the right tool. A function that reaches out through global to modify state that lives entirely outside its own parameters and return value is a function whose behavior you can’t understand by reading its signature: you have to go find every global $counter scattered across the codebase to know who might change it, and in what order. That’s the kind of bug that’s invisible in a five-line example and genuinely painful in a five-thousand-line application. Prefer passing values in as parameters and getting results back as return values (or, once we reach Chapter 5, storing shared state as a property on an object you pass around deliberately). If you find yourself reaching for global, it’s usually a sign the function wants a parameter instead.
static variables inside functions
There’s a second, much better-behaved way for a function to remember something between calls: a static local variable. Unlike an ordinary local variable, which is created fresh and destroyed every time the function runs, a static variable keeps its value from one call to the next, but only that one function can see or touch it.
<?php
declare(strict_types=1);
function nextId(): int
{
static $id = 0;
$id++;
return $id;
}
echo nextId(); // 1
echo nextId(); // 2
echo nextId(); // 3
$id = 0 only runs the very first time nextId() is called; every call after that picks up wherever the previous one left off. Nothing outside nextId() can read or reset $id; there’s no global-style leak here, just a function with a genuinely private memory of its own. It’s a handy pattern for small counters, simple caches, or “have I already done this setup step” flags, without reaching for a full object just to hold one number.
A brief, honest word about garbage collection
You may hear people mention PHP’s “garbage collector,” usually in the context of memory leaks or long-running scripts, and it’s worth knowing roughly what that means even though you’ll rarely think about it day to day.
Every value PHP creates (every array, every object) is tracked with a reference count: how many variables currently point at it. When that count drops to zero, because the last variable pointing at it went out of scope or got reassigned, PHP frees the memory immediately. This is the mechanism quietly making copy-on-write work, back in the first section of this chapter: PHP knows exactly how many places are sharing a given array at any moment.
Reference counting alone has one blind spot: two objects that reference each other form a cycle, and a cycle can end up with nothing left in the rest of your program pointing at it, while the two objects inside the cycle still point at each other, so their reference counts never quite reach zero. PHP handles this with a separate cycle collector that runs periodically, finds these orphaned cycles, and cleans them up anyway.
The honest summary: you don’t manage memory in PHP. There’s no malloc, no free, no manual bookkeeping of who owns what. Values disappear when nothing needs them anymore, and PHP figures out “nothing needs them anymore” for you, cycles included. That’s not a gap compared to languages that make you think about memory explicitly; it’s the entire point. Keep the vocabulary in your back pocket for the rare occasion you’re debugging memory growth in a long-running script, and otherwise let it do its job.
Using Classes to Structure Related Data
You’ve been using arrays to group related values since Chapter 3: a cart’s items, a set of prices keyed by name, a $prices array passed into a function. That works, right up until it doesn’t. An associative array has no fixed shape: nothing stops you from misspelling a key, nothing tells you which keys are supposed to exist, and nothing attaches the operations you perform on that data to the data itself. You end up with a $product array and, somewhere else entirely, a calculateTotal($product) function that only works correctly if it agrees with every other function in the codebase about which keys $product is supposed to have.
Classes fix this by letting you define a shape for a piece of data: named, typed properties that always exist, with the operations that make sense on that data living right alongside it as methods. You briefly met objects in passing back in Data Types, and again in Chapter 4, where you saw the single most important thing to know about them: unlike arrays, objects aren’t copied when you assign or pass them around; every variable holding one holds a handle to the same underlying instance. This chapter is where objects stop being background context and become something you build yourself.
We’ll start with the mechanics: the class keyword, typed properties, visibility, and the new keyword that brings an instance into existence. Then we’ll work through one small example end to end, the way you’d actually reach for a class in real code: not because a textbook said so, but because the loose-array version of the same problem was getting unwieldy. Finally, we’ll look at methods properly, $this, and PHP’s modern shorthand for writing constructors, which cuts away a surprising amount of the boilerplate that older PHP code is full of.
By the end of this chapter you’ll have the tools to replace a “bag of arrays held together by convention” with something the language itself can hold you to, and you’ll have a habit you’ll use for the rest of the book.
Defining and Instantiating Classes
A class is a blueprint. It describes what data an object of that type holds and, eventually, what it can do. On its own, though, a class produces nothing. You have to ask PHP to build one, with new.
Defining a class
<?php
declare(strict_types=1);
class Rectangle
{
public float $width;
public float $height;
}
class Rectangle { ... } declares the blueprint. Inside it, public float $width; declares a typed property: a named slot every Rectangle object will have, and a type PHP will enforce whenever something tries to assign to it. This should feel familiar: it’s the same type declarations you’ve been putting on function parameters since Chapter 3, applied to a piece of data that lives on an object instead of a variable that lives in a function call.
Instantiating a class
new creates an actual object, an instance, from the blueprint:
<?php
$rect = new Rectangle();
$rect->width = 10.0;
$rect->height = 4.0;
echo $rect->width; // 10
echo $rect->height; // 4
new Rectangle() gives you a real Rectangle object, with its own independent $width and $height, and assigns it to $rect. The -> operator reaches into an object to read or write one of its properties: think of it as the object equivalent of [] on an array, though we’ll see shortly that objects are usually better behaved about what you’re allowed to put in there. Create a second Rectangle, and it’s a genuinely separate object, with its own storage:
<?php
$rect2 = new Rectangle();
$rect2->width = 3.0;
$rect2->height = 3.0;
echo $rect->width; // 10, untouched by $rect2
echo $rect2->width; // 3
This is worth pausing on, given what Chapter 4 taught you about objects sharing handles: $rect and $rect2 aren’t two names for the same object; they’re two separate new calls, producing two separate instances. The “objects alias, not copy” rule from Chapter 4 is about what happens when you assign an existing object to another variable ($a = $b), not about what happens every time you write new. Each new genuinely builds a fresh object.
Constructing with __construct
Setting each property by hand after new, as above, works, but it’s easy to forget one, and there’s a window where a half-built Rectangle exists with some properties still unset. PHP lets you define a special method, __construct(), that runs automatically the moment an object is created, so you can guarantee it’s fully and correctly initialized from the start:
<?php
declare(strict_types=1);
class Rectangle
{
public float $width;
public float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
}
$rect = new Rectangle(10.0, 4.0);
echo $rect->width; // 10
echo $rect->height; // 4
Whatever arguments you pass to new Rectangle(...) are handed straight to __construct(). Inside it, $this refers to the object currently being built: $this->width = $width takes the incoming parameter and stores it on the object’s own $width property. We’ll look at $this more closely, and at a much shorter way to write exactly this constructor, in Methods and Constructor Promotion.
Visibility: public, private, protected
Every property and method in PHP has a visibility, and so far everything above has been public: reachable from anywhere, including code entirely outside the class. That’s often not what you want. Mark a property private, and only code inside the class itself can read or write it:
<?php
declare(strict_types=1);
class Rectangle
{
private float $width;
private float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
}
$rect = new Rectangle(10.0, 4.0);
echo $rect->width; // Error: Cannot access private property Rectangle::$width
This is a deliberate restriction, not a bug to work around. Once $width is private, the only way anything outside Rectangle can learn or change it is through methods Rectangle itself chooses to expose, which means Rectangle gets to enforce its own rules about what a valid width even looks like, rather than trusting every caller everywhere to behave. protected sits in between: invisible from outside the class, but visible to any class that later extends it, a distinction that matters once we reach inheritance in Chapter 17. For now, a reasonable default: reach for private unless you have a specific reason a property needs to be public, and expose access through methods when outside code genuinely needs it.
An Example Program Using Classes
Let’s see why you’d actually reach for a class, by writing the same small problem two ways.
The problem, with loose arrays
Say you’re building the start of a shop. Each product has a name, a price, and a quantity in the cart, and you need to compute a line total. The array-based version looks perfectly reasonable at first:
<?php
declare(strict_types=1);
function lineTotal(array $product): float
{
return $product['price'] * $product['quantity'];
}
$item = [
'name' => 'Coffee mug',
'price' => 8.50,
'quantity' => 3,
];
echo lineTotal($item); // 25.5
It works, right up until it doesn’t. Nothing stops a typo:
<?php
$item = [
'name' => 'Coffee mug',
'prise' => 8.50, // typo, silently different key
'quantity' => 3,
];
echo lineTotal($item); // Warning: Undefined array key "price"
That warning fires deep inside lineTotal(), far from where the actual mistake was made. Nothing in $item’s definition told you what keys it was supposed to have, and nothing checked that price was even a number until the moment it was multiplied. As the shop grows (discounts, tax rates, stock levels), every function touching a product array has to independently agree on the same set of magic string keys, and every one of them is a typo away from failing quietly or loudly, at runtime, nowhere near the actual bug.
The same problem, with a class
<?php
declare(strict_types=1);
class Product
{
public string $name;
public float $price;
public int $quantity;
public function __construct(string $name, float $price, int $quantity)
{
$this->name = $name;
$this->price = $price;
$this->quantity = $quantity;
}
public function totalPrice(): float
{
return $this->price * $this->quantity;
}
}
Product now names its shape once, in one place. Try to build one with a typo’d property name, and there’s nothing to typo: new Product(...) demands exactly name, price, and quantity, in that order, each with a declared type. Get the types wrong and, with strict_types on, PHP stops you immediately rather than letting a string quietly stand in for a price:
<?php
$mug = new Product('Coffee mug', 8.50, 3);
echo $mug->totalPrice(); // 25.5
totalPrice() lives on Product itself now, not as a free-floating function somewhere else that has to be told the shape of its argument. Anyone holding a Product, anywhere in the codebase, written by anyone, can call $product->totalPrice() and get the right answer, because the logic for computing it travels with the data it operates on.
Using it in a small program
A tiny cart, built from a handful of Product objects:
<?php
declare(strict_types=1);
$cart = [
new Product('Coffee mug', 8.50, 3),
new Product('Notebook', 4.25, 2),
new Product('Pen', 1.10, 5),
];
$total = 0.0;
foreach ($cart as $product) {
echo "{$product->name}: \${$product->totalPrice()}\n";
$total += $product->totalPrice();
}
echo "Total: \${$total}\n";
$ php cart.php
Coffee mug: $25.5
Notebook: $8.5
Pen: $5.5
Total: $39.5
Notice $cart is still an ordinary array: classes don’t replace arrays, they replace what you’d otherwise be forced to stuff into one. Here the array is doing exactly what it’s good at, holding an ordered list of things, while each individual thing is a Product that knows its own shape and its own arithmetic. That combination, plain arrays for collections, classes for the things they collect, is the pattern you’ll use constantly for the rest of this book.
Methods and Constructor Promotion
A method is just a function that lives inside a class. You’ve already written one, totalPrice() on Product, in the previous section, but it’s worth looking at what makes it different from an ordinary function, and then at PHP’s shortest, most modern way of writing the constructor that so often accompanies one.
$this
Inside a method, $this refers to the specific object the method was called on. It’s how a method reaches the data that belongs to this particular instance, as opposed to some other instance of the same class:
<?php
declare(strict_types=1);
class Product
{
public string $name;
public float $price;
public int $quantity;
public function __construct(string $name, float $price, int $quantity)
{
$this->name = $name;
$this->price = $price;
$this->quantity = $quantity;
}
public function totalPrice(): float
{
return $this->price * $this->quantity;
}
public function applyDiscount(float $percentage): void
{
$this->price -= $this->price * ($percentage / 100);
}
}
$mug = new Product('Coffee mug', 8.50, 3);
$mug->applyDiscount(10);
echo $mug->price; // 7.65
applyDiscount() doesn’t take the product as a parameter; it doesn’t need to, because $this already is the product it was called on. Call $mug->applyDiscount(10), and inside the method, $this is $mug; call the same method on a different Product, and $this is that one instead. It’s implicit, always available inside any non-static method, and it’s how an object’s methods stay in sync with that same object’s data without you having to pass the object into every one of its own methods by hand.
The constructor, the long way
Look back at Product’s constructor above. It’s a common shape: three parameters in, three matching property assignments, one line each, no logic beyond “put this where it belongs.” This pattern is common enough in PHP, and repetitive enough, that it earned its own shorthand.
Constructor property promotion
PHP 8 lets you declare a property and assign it from a constructor parameter in a single spot, by adding a visibility keyword directly to the parameter itself:
<?php
declare(strict_types=1);
class Product
{
public function __construct(
public string $name,
public float $price,
public int $quantity,
) {
}
public function totalPrice(): float
{
return $this->price * $this->quantity;
}
}
$mug = new Product('Coffee mug', 8.50, 3);
echo $mug->totalPrice(); // 25.5
Compare this directly against the version at the top of this section. Both classes behave identically from the outside: same properties, same types, same constructor signature. But the promoted version has no separate property declarations, no $this->name = $name; repeated three times, and an empty constructor body. Writing public string $name as a constructor parameter does three things at once: declares the property, sets its type, and assigns the incoming argument to it, all in the one place you’d otherwise have written it twice.
This is the idiomatic, modern way to write a constructor whose only job is “store what I was given,” which describes a large share of the constructors you’ll write in real PHP code. You’ll see it constantly from here on in this book.
readonly properties, briefly
One more keyword worth knowing now that you’ve seen promotion, since the two are so often paired: mark a promoted property readonly, and it can be set once, during construction, and never reassigned after:
<?php
declare(strict_types=1);
class Product
{
public function __construct(
public readonly string $name,
public float $price,
public int $quantity,
) {
}
}
$mug = new Product('Coffee mug', 8.50, 3);
$mug->name = 'Travel mug'; // Error: Cannot modify readonly property Product::$name
A product’s price and quantity are expected to change (that’s the whole point of applyDiscount()), but there’s rarely a good reason for its name to change after it’s created. readonly lets you say so directly in the class definition, and PHP enforces it, rather than that guarantee living only as a comment or a convention someone eventually forgets. We’ll lean on readonly again once enums and value objects enter the picture in the next chapter.
Enums and Pattern Matching
You met match briefly back in Control Flow, sizing up an HTTP status code, and you’ve been using classes since Chapter 5 to give shape to data that used to live loosely in arrays. This chapter brings those two ideas together to solve a problem that shows up in almost every real program: a value that can only ever be one of a fixed, known set of options.
Think about it: an order’s status is pending, shipped, or cancelled; never anything else. A card suit is one of four values, full stop. Before PHP 8.1, you handled this with a string or an integer constant and a lot of hope: nothing stopped you from typing 'shiped' instead of 'shipped', and nothing told you, anywhere, what the full set of valid values even was. Enums fix this properly: a real type, checked by the engine, that can only ever hold one of the cases you defined.
This chapter covers how to define an enum, both the plain kind and the kind backed by a string or integer value for storage; how to give an enum its own methods, the same way a class can; and how match becomes considerably more powerful once it’s comparing against enum cases instead of loose strings. We’ll close with the nullsafe operator, ?->, a small piece of syntax that pairs naturally with everything else in this chapter because it’s really about the same underlying concern: handling a fixed, known set of possibilities (in that case, “something” or “nothing”) without a pile of defensive if checks getting in the way of what the code is actually trying to say.
Defining an Enum
Pure enums
An enum defines a type with a fixed, closed set of possible values, called cases:
<?php
declare(strict_types=1);
enum Suit
{
case Hearts;
case Diamonds;
case Clubs;
case Spades;
}
$card = Suit::Hearts;
var_dump($card); // enum(Suit::Hearts)
var_dump($card === Suit::Hearts); // true
Suit::Hearts is a real, singleton value: there is exactly one Suit::Hearts in your entire program, ever, no matter how many variables point at it. That’s a meaningfully stronger guarantee than a string constant ever gave you: a variable typed Suit genuinely cannot hold anything other than one of the four cases you declared. Try to assign it a typo’d value, or a plain string, and PHP stops you at the type level, not with a bug report three weeks later.
<?php
declare(strict_types=1);
function describe(Suit $suit): string
{
return "You drew a {$suit->name}.";
}
echo describe(Suit::Spades); // You drew a Spades.
Every case has a built-in ->name property: the exact identifier you declared it with, as a string. Handy for logging or debugging; not something you should build serious program logic around, since it’s really just the case’s label.
Backed enums
A pure enum’s cases don’t have an underlying value beyond themselves; they’re not secretly strings or integers. Often, though, you need one: to store a status in a database column, to serialize it into JSON for an API response, to compare it against a value that arrived from outside your program entirely. For that, PHP gives you a backed enum, where every case is tied to a scalar value you choose:
<?php
declare(strict_types=1);
enum Status: string
{
case Pending = 'pending';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
$status = Status::Shipped;
echo $status->value; // shipped
: string after the enum name declares it as backed by strings: every case must then declare a matching string value, and PHP enforces that at definition time. Integers work the same way (enum Status: int), but strings are by far the more common choice in practice, since a string like 'shipped' is self-describing the moment you see it in a database row or a JSON payload, where a bare 2 tells you nothing without cross-referencing the enum definition.
Backed enums give you two extra ways to build a case from its underlying value:
<?php
$status = Status::from('shipped'); // Status::Shipped
echo $status->name; // Shipped
$status = Status::tryFrom('bogus'); // null, no matching case
var_dump($status);
from() converts a raw value into the matching case, and throws a ValueError if nothing matches: reach for it when an unrecognized value genuinely represents a bug you want to know about immediately. tryFrom() is the forgiving sibling: it returns null instead of throwing, which is exactly what you want when the value is arriving from somewhere you don’t fully trust, like user input or an external API, and “not a valid status” is a case you intend to handle rather than crash on.
Enums can have methods
An enum isn’t just a list of named values; it can carry behavior too, the same way a class can:
<?php
declare(strict_types=1);
enum Status: string
{
case Pending = 'pending';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
Status::Pending => 'Awaiting shipment',
Status::Shipped => 'On its way',
Status::Cancelled => 'Order cancelled',
};
}
}
echo Status::Shipped->label(); // On its way
label() behaves exactly like a method on any class: $this inside it refers to the specific case it was called on, just as it referred to a specific object back in Chapter 5. This is a genuinely good place to put presentation logic that would otherwise end up scattered across your codebase as a pile of if ($status === 'shipped') { ... } checks: the mapping from a case to a human-readable label lives in exactly one place, right next to the cases themselves. We’ll look at match used this way (matching directly on an enum case rather than a loose condition) in real depth in the next section.
The match Expression
Back in Control Flow, you saw match(true) used to test a series of conditions against an HTTP status code, a neat trick, but not actually match doing what it’s best at. match’s real strength shows up when you’re comparing one value directly against a small, known set of possibilities, which is precisely what an enum gives you.
Matching directly on an enum case
<?php
declare(strict_types=1);
enum Status: string
{
case Pending = 'pending';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
function nextAction(Status $status): string
{
return match ($status) {
Status::Pending => 'Pack the order',
Status::Shipped => 'Notify the customer',
Status::Cancelled => 'Issue a refund',
};
}
echo nextAction(Status::Pending); // Pack the order
No true, no comparison operators, no range checks: match ($status) compares $status directly against each arm using strict (===) comparison, and returns the value beside whichever arm matched. This is match at its cleanest: read top to bottom, it’s a direct, literal table mapping each possible case to what should happen for it, and there’s no ambiguity about what’s being compared against what.
Multiple conditions per arm
You’re not limited to one value per arm: separate several with commas, and any one of them matching is enough:
<?php
declare(strict_types=1);
function isFinal(Status $status): bool
{
return match ($status) {
Status::Shipped, Status::Cancelled => true,
Status::Pending => false,
};
}
var_dump(isFinal(Status::Shipped)); // true
var_dump(isFinal(Status::Cancelled)); // true
var_dump(isFinal(Status::Pending)); // false
Status::Shipped, Status::Cancelled => true reads naturally as “either of these, same outcome”: considerably clearer than writing the same arm twice, or reaching for an || inside a match(true) construction.
Exhaustiveness is enforced
Here’s the detail that makes match more than a tidier switch: every possible case has to be accounted for, either by name or with a default arm. Leave one out, and PHP doesn’t silently skip it: it throws:
<?php
declare(strict_types=1);
enum Status: string
{
case Pending = 'pending';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
case Returned = 'returned'; // added later
}
function nextAction(Status $status): string
{
return match ($status) {
Status::Pending => 'Pack the order',
Status::Shipped => 'Notify the customer',
Status::Cancelled => 'Issue a refund',
// forgot to add a Returned arm
};
}
nextAction(Status::Returned); // UnhandledMatchError: Unhandled match case Status::Returned
This is a real safety feature, not just strictness for its own sake. Add a new case to an enum months from now, forget to update one of the several match expressions scattered around your codebase that switch on it, and PHP tells you immediately, loudly, at the exact call site that needed updating, instead of a switch statement silently falling through to nothing, or an if chain quietly doing the wrong thing for a value nobody anticipated. If a match genuinely doesn’t need to handle every case explicitly, because most of them share the same fallback behavior, add a default arm, exactly like switch has always had, and it soaks up anything not named above it.
Concise Control Flow with match and ?->
We’ll close this chapter with a piece of syntax that has nothing to do with enums directly, but solves a closely related problem: handling a value that might be nothing at all, without burying the code that actually matters under a stack of defensive checks.
The nullsafe operator
Recall from Data Types that null means “no value at all,” and recall from Chapter 5 that -> reaches into an object to get at a property or call a method. Combine the two, and you get a genuinely common problem: what happens when you try to use -> on something that might be null?
<?php
declare(strict_types=1);
class Address
{
public function __construct(
public string $city,
) {
}
}
class Customer
{
public function __construct(
public string $name,
public ?Address $address = null,
) {
}
}
$customer = new Customer('Ada');
echo $customer->address->city; // Error: Attempt to read property "city" on null
$customer has no address on file, so $customer->address is null, and reaching further with ->city blows up immediately. The traditional fix is a guard before every such access:
<?php
$city = null;
if ($customer->address !== null) {
$city = $customer->address->city;
}
echo $city ?? 'No address on file';
That works, but it doesn’t scale: chain a few more levels ($order->customer->address->city, say) and you either nest a guard for every link in the chain, or write one large condition that’s checking three different things at once and calling none of them out individually.
The nullsafe operator, ?->, does this in one step:
<?php
declare(strict_types=1);
$city = $customer->address?->city;
echo $city ?? 'No address on file';
?-> checks whether the thing on its left is null before attempting the access. If it is, the whole expression short-circuits to null immediately: no error, no exception, just null, ready to be handled with ?? or however else you’d handle a missing value. If it isn’t null, ?-> behaves exactly like ordinary ->. Chain several together, and the short-circuiting propagates through the whole chain: $order?->customer?->address?->city stops at the first null it finds and returns null for the entire expression, without ever attempting the accesses after it.
One thing worth being deliberate about: ?-> is for “this might legitimately be absent, and that’s fine” situations, an optional address, an optional related record. It’s not a substitute for thinking about whether null should be possible at all. Sprinkling ?-> everywhere out of habit tends to paper over a design that hasn’t decided what’s actually optional and what genuinely shouldn’t ever be missing. Use it where absence is an expected, normal outcome; let a real type error surface anywhere else.
match as the clean alternative to a long if/elseif chain
We’ve spent this chapter pairing match with enums, but it’s worth stepping back to the broader point: match is PHP’s answer to a long if/elseif chain checking one thing against several discrete possibilities, enum or not.
<?php
declare(strict_types=1);
function shippingCost(string $countryCode): float
{
if ($countryCode === 'US') {
return 5.00;
} elseif ($countryCode === 'CA') {
return 7.50;
} elseif ($countryCode === 'FR' || $countryCode === 'DE') {
return 9.00;
} else {
return 15.00;
}
}
Rewritten as a match, the same logic reads as a table rather than a sequence of decisions to follow one after another:
<?php
declare(strict_types=1);
function shippingCost(string $countryCode): float
{
return match ($countryCode) {
'US' => 5.00,
'CA' => 7.50,
'FR', 'DE' => 9.00,
default => 15.00,
};
}
Shorter, and, more importantly, every branch is visibly an alternative to every other one, at a glance, rather than a chain of elseifs you have to read sequentially to be sure you understand the fallthrough. That’s the instinct to carry forward: reach for if/elseif when your conditions are genuinely different kinds of checks (ranges, combinations, unrelated booleans), and reach for match the moment you notice you’re really just asking “which one of these known values is it”: enums included, but far from the only place that question comes up.
Namespaces, Packages, and Composer
Every example so far has lived in one file. That’s about to stop being true. Once you have more than a class or two (and after Chapter 5 and Chapter 6, you do) cramming everything into a single script stops being convenient and starts being a liability. You need a way to split code across files, and a way to make sure that when two files both define something called Product, PHP doesn’t get confused about which one you meant.
That second problem is what namespaces are for. A namespace is nothing more exotic than a prefix on a name, but it solves a real problem: the moment you install a third-party package with Composer, PHP’s package manager, you’re sharing a project with code you didn’t write and can’t rename. Without namespaces, the first library that also happens to define a Collection or a Response class would collide with yours, or with each other. With them, App\Models\Product and Vendor\Package\Product are simply two different names, and PHP never has to guess.
This chapter builds that up piece by piece. We’ll start by meeting Composer itself, then declare namespaces for your own classes, use the use keyword to refer to them (and to imported code) without spelling out the full path every time, and lay out a small multi-file project the way real PHP projects are laid out: a src/ directory whose folder structure mirrors its namespaces. Then you’ll wire it all together with PSR-4, the autoloading convention that lets Composer find your classes the instant you write them, with no manual require in sight.
That last part is the payoff. require 'vendor/autoload.php' does real work the moment you install your first package: finding and loading it. By the end of this chapter, that same one line will also find and load your own code. You’ll add a new class to your project, use it, and it will simply be there: no bookkeeping, no growing list of require statements at the top of every file.
Hello, Composer!
A single-file script like hello.php doesn’t need help managing dependencies, because it has none. Real projects do, almost immediately: a testing library here, an HTTP client there. And PHP’s answer to “how do I pull in someone else’s code without copy-pasting it into my repo” is Composer.
If you’ve used npm, pip, or cargo before, you already understand Composer’s job. If you haven’t, don’t worry: we’ll build the intuition from scratch.
Installing Composer
On macOS or Linux, the quickest path is usually your package manager:
$ brew install composer
Everywhere else, or if you want the canonical method, the official download page has a short install script. Either way, confirm it worked:
$ composer --version
Composer version 2.7.6 2024-...
Starting a project
Inside an empty directory, run:
$ composer init
Composer will ask you a handful of questions: package name, description, author, license, and so on. For now, you can accept the defaults on most of them or just press Enter through the whole thing; none of it is permanent. What matters is what it leaves behind: a composer.json file.
{
"name": "you/hello-composer",
"require": {}
}
This file is the source of truth for your project’s dependencies: think of it as an ingredients list. composer.json is meant to be committed to version control. What it pulls in, on the other hand, is not.
Requiring your first package
Let’s add something real. nunomaduro/termwind is a small library for styling terminal output, nothing essential, just enough to prove the mechanism works:
$ composer require nunomaduro/termwind
Two things appear: a vendor/ directory, containing the actual downloaded code, and a composer.lock file, which pins the exact versions installed, down to the last commit, so that everyone on your team, and your production server, installs identically. composer.json says what you’re willing to accept; composer.lock says what you actually got. Commit the lock file too.
Now use it:
<?php
require 'vendor/autoload.php';
use function Termwind\render;
render('<div class="p-1 bg-green-400">Hello, Composer!</div>');
That require 'vendor/autoload.php'; line is the one that matters most. Composer generates an autoloader: a bit of PHP that knows how to find and load any class from any package you’ve installed, on demand, without you writing a single require for each one. Include that one file, once, at the top of your entry point, and every dependency you add from here on just… works.
What we’ll do with this
Everything so far has lived in a single file, so Composer’s autoloader hasn’t had much to do beyond loading Termwind. That changes immediately: the rest of this chapter is about splitting code across files and packages, and that same autoloader is also how your own classes get found, not just third-party ones.
Packages and Autoloading
You already know the mechanics of installing a package: composer require pulls it into vendor/, and require 'vendor/autoload.php' makes every class inside it available. What we skipped over a moment ago, on purpose, was how that second part actually works, because the answer explains why the rest of this chapter exists.
The problem autoloading solves
Imagine PHP without any of this. You write a Cart class in one file and a Product class in another, and your entry-point script needs both:
<?php
require 'Product.php';
require 'Cart.php';
$product = new Product('Keyboard', 49.00);
$cart = new Cart();
$cart->add($product);
Two classes, two require lines, kept in sync by hand. That’s manageable. Now imagine forty classes, spread across a dozen packages you didn’t write, each depending on others in an order you’d have to work out yourself. Nobody does this anymore, and for good reason: it’s tedious and it breaks the moment you rename a file.
spl_autoload_register()
PHP has a built-in escape hatch for exactly this: spl_autoload_register(). It lets you register a function that PHP calls automatically the first time it encounters a class name it doesn’t recognize yet: instead of failing immediately, PHP gives your function a chance to go find and load it.
<?php
spl_autoload_register(function (string $className): void {
$file = __DIR__ . '/' . $className . '.php';
if (file_exists($file)) {
require $file;
}
});
$cart = new Cart(); // Cart.php is loaded automatically, on first use
You could write your own version of this, and a lot of PHP projects did, before Composer existed. It works, but the moment a package you rely on ships its own hand-rolled autoloader with slightly different rules, you’re back to coordinating things by hand.
What Composer actually generates
Every time you run composer install or composer require, Composer regenerates the files inside vendor/composer/, including autoload_psr4.php, a plain PHP array mapping namespace prefixes to directories. vendor/autoload.php registers one autoloader, built from that map, via spl_autoload_register(), and from then on any class from any installed package resolves automatically, no matter which package declared it.
This works because packages don’t just dump files into a shared folder: each one declares, in its own composer.json, which namespace prefix maps to which directory. A small preview of what that declaration looks like (we’ll build one properly in PSR-4):
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
That’s really the whole trick: a namespace is a promise about where to find the file, and PSR-4 is the rule that turns the promise into a file path. Composer’s autoloader just executes that rule fast.
Why this needs namespaces at all
Here’s the part that matters for the rest of the chapter: this scheme only works if class names stay unique across every package installed in your project. A Product class from your own code and a Product class from some e-commerce package would otherwise be indistinguishable: PHP wouldn’t know which Product.php to load, and neither would you, reading the code six months later.
Namespaces are how PHP avoids that collision entirely, by making Product shorthand for something more specific: App\Models\Product, say, versus Vendor\Ecommerce\Product. Two different names, two different files, no ambiguity. That’s what we cover next.
Controlling Scope and Visibility with Namespaces
A namespace is a prefix. That’s the whole concept, and it’s worth saying plainly before the syntax makes it look more complicated than it is: App\Models\Product is just the name Product, living inside the namespace App\Models, the same way /home/damien/notes.txt is just notes.txt, living inside /home/damien. Nothing about the class itself changes. What changes is how you, and PHP, refer to it unambiguously.
Declaring a namespace
A namespace declaration is the first statement in a file: before it, only a docblock or declare(strict_types=1) is allowed:
<?php
declare(strict_types=1);
namespace App\Models;
class Product
{
public function __construct(
public readonly string $name,
public readonly float $price,
) {
}
}
Everything declared in this file (the Product class, and any other class, interface, or function you add to it) now lives under App\Models. Its full, unambiguous name is App\Models\Product. Within this same file, and within any other file that also declares namespace App\Models;, you can still refer to it as plain Product: PHP resolves unqualified names against the current namespace first.
Why bother
Here’s the scenario this is built for. Say your project uses a third-party library that ships its own Collection class: plenty of packages do, it’s a natural name for “a bunch of things with some helper methods.” You also want a Collection class of your own, for a stamp-collecting app, say. Without namespaces, PHP would see two classes both trying to be called Collection and refuse to load the second one: a fatal error, and not a subtle one.
With namespaces, there’s no conflict at all:
<?php
namespace App\Models;
class Collection
{
// your Collection, entirely unrelated to anyone else's
}
<?php
// Illuminate\Support\Collection, from a package you installed
namespace Illuminate\Support;
class Collection
{
// their Collection
}
Two classes, same short name, no collision: App\Models\Collection and Illuminate\Support\Collection are simply different identifiers. This is the actual reason namespaces exist. It has nothing to do with “organizing code” in the abstract and everything to do with the fact that your project will, almost immediately, contain code from people you’ve never met, and none of you coordinated on naming in advance.
Fully qualified names
You can always refer to a class by its full path, regardless of what namespace you’re currently in, by writing it out completely: a fully qualified name:
<?php
namespace App\Services;
function makeProduct(): \App\Models\Product
{
return new \App\Models\Product('Keyboard', 49.00);
}
Notice the leading backslash. Inside a namespace, an unqualified name like Product is resolved relative to the current namespace: PHP would look for App\Services\Product, which doesn’t exist. A leading \ says “start from the very top, the global namespace”; it’s the same trick you need for PHP’s own built-in classes when you’re inside a namespace:
<?php
namespace App\Services;
function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
DateTimeImmutable isn’t namespaced; it lives in the global namespace, same as Exception, ArrayObject, and every other built-in class. Once your file declares its own namespace, you need that leading backslash to reach them, or the use keyword to import them once and skip the backslash everywhere else, which is exactly where we’re headed next.
A note on functions and constants
Namespaces apply to functions and constants too, not just classes: namespace App\Helpers; followed by function slugify(string $s): string { ... } gives you App\Helpers\slugify(). In practice this comes up less often than you’d expect: PHP falls back to the global namespace automatically for unqualified function and constant names if no namespaced version exists, which is why you can keep calling strlen() and array_map() from inside a namespaced file without a second thought. Classes get no such fallback: get the namespace wrong for a class and PHP simply won’t find it.
Referring to Code with the use Keyword
Writing \App\Models\Product every single time you need a Product gets old fast, and it clutters up code that should be about your business logic, not about where files live. The use keyword lets you import a name once, at the top of a file, and then refer to it by its short name for the rest of that file.
Basic imports
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Product;
function makeProduct(string $name, float $price): Product
{
return new Product($name, $price);
}
One use statement, and Product means App\Models\Product for the rest of the file: no backslash, no full path, no ambiguity. use statements go directly under the namespace declaration, before anything else in the file. This is purely a per-file convenience: importing a class in one file has no effect on any other file, which also means you need the same use line again in every file that needs it. That repetition is normal, not a sign you’re doing something wrong.
Aliasing with as
Sometimes the short name is already taken: you’re using two different packages that both happen to export something called Collection, or you want a locally clearer name than the one a library chose. use ... as renames the import for the current file only:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Product;
use App\Models\Product as ProductModel;
use Vendor\Ecommerce\Product as ExternalProduct;
function convert(ExternalProduct $external): ProductModel
{
return new ProductModel($external->title, $external->cost);
}
The alias only exists inside this file. App\Models\Product and Vendor\Ecommerce\Product haven’t changed names anywhere else in the project; you’ve just given yourself two clearly distinct local labels to work with in the one place that needed to talk about both at once.
Importing several names at once
When a file leans heavily on one namespace, you can group the imports instead of repeating the prefix on every line:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\{Product, Category, Warehouse};
This is equivalent to three separate use statements. It’s a matter of taste: some teams like the compactness, others find one-import-per-line easier to scan in a diff. Either is fine; pick one and be consistent within a project.
Importing functions and constants
use isn’t only for classes. If you’ve namespaced a helper function or a constant (as mentioned in the previous section, this is rarer than namespacing classes, but it happens), you import them with use function and use const:
<?php
declare(strict_types=1);
namespace App\Services;
use function App\Helpers\slugify;
use const App\Helpers\DEFAULT_LOCALE;
$slug = slugify('Hello, Composer!');
You already saw this exact form earlier in this chapter, in Hello, Composer!, with use function Termwind\render;; at the time it probably looked like a small piece of magic. It’s the same mechanism as everything else in this section: an import, scoped to one file, that lets you write a short name instead of a long, fully qualified one.
What you’re actually buying
None of this changes what any class, function, or constant is: a use statement is bookkeeping, not behavior. What it buys you is code that reads the way you think about it: new Product(...) instead of new \App\Models\Product(...), everywhere it matters, with the one line at the top of the file doing all the work of disambiguation. Combined with the namespace declarations from the previous section, you now have everything you need to write code that won’t collide with anyone else’s. The next question is how to spread that code across files and folders sensibly, which is where we’re headed.
Organizing a Multi-File Project
You now know how to declare a namespace and how to import from one. What’s still missing is the connection between namespaces and the filesystem, because so far, PHP has no idea that App\Models\Product is supposed to live in any particular file at all. You could technically put it anywhere. You shouldn’t, and in this section we’ll set up the layout that makes “anywhere” stop being an option worth considering.
One class, one file
The convention (not a language rule, but one followed closely enough across the PHP ecosystem that deviating from it will confuse the next person who opens your project) is one class, interface, trait, or enum per file, and the file name matches the class name exactly, including case. Product lives in Product.php. Not product.php, not models.php with three classes crammed into it.
This feels restrictive coming from scripts where everything lived in one file, but it pays for itself the moment a project has more than a handful of classes: you can find any class by its name alone, without grepping.
A folder that mirrors the namespace
The second half of the convention is that your folder structure mirrors your namespace structure. A small project might look like this:
$ find src -type f
src/Models/Product.php
src/Models/Category.php
src/Services/Cart.php
src/Services/PricingCalculator.php
And the namespace inside each file matches its path under src/:
<?php
declare(strict_types=1);
namespace App\Models;
class Product
{
public function __construct(
public readonly string $name,
public readonly float $price,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Models;
class Category
{
public function __construct(
public readonly string $name,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Product;
class Cart
{
/** @var Product[] */
private array $items = [];
public function add(Product $product): void
{
$this->items[] = $product;
}
public function total(): float
{
return array_sum(array_map(
fn (Product $product) => $product->price,
$this->items,
));
}
}
App\Models\Product sits at src/Models/Product.php. App\Services\Cart sits at src/Services/Cart.php. The App prefix itself doesn’t correspond to a folder named App: it corresponds to src/ as a whole, a mapping you declare once, which is the subject of the next section.
Putting it together from an entry point
With that layout in place, a small entry-point script (say public/index.php, or a one-off command you run with php run.php) just imports what it needs and gets on with it:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use App\Models\Product;
use App\Services\Cart;
$cart = new Cart();
$cart->add(new Product('Keyboard', 49.00));
$cart->add(new Product('Mouse', 25.00));
echo $cart->total() . "\n";
No require for Product.php or Cart.php anywhere in sight: just vendor/autoload.php, the same line from Hello, Composer!, earlier in this chapter. That’s not a coincidence, and it isn’t automatic magic either: it works because of one small block of configuration connecting the App\ namespace prefix to the src/ folder, which is exactly what PSR-4 is, and exactly what’s next.
Separating Classes into Different Files (PSR-4)
Everything in this chapter has been building toward one small block of JSON. You’ve namespaced your classes, imported them with use, and laid out src/ to mirror those namespaces, but nothing so far has actually told Composer that any of this is connected. That’s PSR-4’s job: a published standard, from the PHP-FIG group that coordinates conventions like this across the ecosystem, that defines exactly how a namespace maps to a directory on disk.
The rule, precisely
PSR-4 autoloading works on prefixes. You tell Composer: “any class whose name starts with this namespace prefix lives under this base directory, with the rest of the namespace and the class name forming the rest of the path.” Concretely, in composer.json:
{
"name": "you/your-project",
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Given that mapping, Composer resolves App\Models\Product like this: strip the App\ prefix, you’re left with Models\Product; replace the remaining backslashes with slashes and add .php, and you get Models/Product.php; join that to the base directory, and you land on src/Models/Product.php. That’s the entire algorithm: no configuration per class, no manifest of files to maintain, just a rule applied consistently.
Note the double backslash in "App\\": this is a JSON string, so a literal backslash needs escaping. Easy to forget, and Composer will tell you plainly (an autoload path that doesn’t resolve) if you do.
Wiring it up
If you already ran composer init back in Chapter 1, add the autoload block to your existing composer.json by hand, or let Composer manage part of it for you; either way, once it’s there, tell Composer to act on it:
$ composer dump-autoload
Generating autoload files
Generated autoload files
This regenerates the files inside vendor/composer/, including the PSR-4 map we peeked at in Packages and Autoloading. From this point on, require 'vendor/autoload.php' finds your own App\ classes exactly the way it already found any third-party package you’d installed.
When to run it again
Composer’s PSR-4 autoloader resolves paths by rule, not by a fixed list of files, so in most setups a brand new class in the right place is found immediately: no extra step. In practice, though, running composer dump-autoload after adding new classes is a habit worth having anyway: some deployment setups generate an optimized, pre-compiled class map (composer dump-autoload --optimize, or automatically in composer install --no-dev on a production server) that trades that on-the-fly resolution for speed, and an optimized map only knows about classes that existed the last time it was generated. If you add src/Models/Discount.php and PHP suddenly can’t find App\Models\Discount, composer dump-autoload is the first thing to try, and it costs nothing to run when in doubt.
Checking your work
Composer can also tell you when your files and your namespaces have drifted apart: a typo in a namespace line, a class saved under the wrong folder:
$ composer dump-autoload
Generating autoload files
Warning: Ambiguous class resolution, "App\Models\Product" was found in
both "src/Models/Product.php" and "src/Models/product.php", the first
will be used.
Generated autoload files
That’s the whole system, and it’s worth appreciating how little of it you have to think about day to day: namespace your class, put the file where the namespace says it goes, and Composer’s autoloader (the one line you wrote back in Chapter 1 and haven’t touched since) finds it. From here on, every multi-file example in this book assumes exactly this setup: a src/ folder, a App\ namespace, and one autoloader that never needs a manual require added to it again.
Common Collections
You’ve been using arrays since Chapter 3, mostly by glimpse: a $fruits = ["apple", "banana"] here, a foreach there, enough to keep an example moving without stopping to explain itself. That stops now. Arrays are PHP’s single most important data structure, used constantly for things other languages hand off to half a dozen specialized types, and they deserve a chapter that actually does them justice rather than one that assumes you’ll pick up the rest by osmosis.
The reason PHP gets away with one data structure doing so much work is that a PHP array isn’t really a list or a dictionary underneath: it’s an ordered map, always, and “list” versus “dictionary” is just a matter of which keys you happen to be using. Understand that one fact early and a lot of otherwise-surprising behavior (why order is preserved, why array_filter() leaves gaps in the keys, why count() is instant) stops being surprising and starts being obvious.
This chapter also detours into strings, and that’s deliberate rather than a change of subject. Strings and arrays are joined at the hip in everyday PHP: you split one into the other, you glue arrays of strings back together, and the moment your strings contain anything beyond plain ASCII (an accented name, a currency symbol, an emoji) you run straight into UTF-8, which PHP handles, but only if you ask it to correctly.
We’ll cover three things in turn: indexed arrays, the list-style arrays you already have some intuition for; strings, with an honest look at the byte-versus-character distinction that trips up nearly everyone at some point; and associative arrays, where string keys turn the same underlying structure into something closer to a small, flexible record. By the end, you’ll have real command of the tools you’ll reach for in nearly every PHP program you write from here on.
Storing Lists of Values with Indexed Arrays
An indexed array is what most languages just call an array or a list: an ordered sequence of values, each reachable by a numeric position starting at 0. In PHP, you build one with square brackets:
<?php
declare(strict_types=1);
$fruits = ['apple', 'banana', 'cherry'];
echo $fruits[0] . "\n"; // apple
echo $fruits[2] . "\n"; // cherry
echo count($fruits) . "\n"; // 3
count() is the function you’ll reach for constantly: it’s O(1), an instant lookup, not a walk through the array, so never hesitate to call it inside a loop condition.
Appending
You rarely build an array fully formed. More often you start empty and grow it, and PHP’s syntax for “add this to the end” is [] with nothing inside the brackets:
<?php
declare(strict_types=1);
$shoppingList = [];
$shoppingList[] = 'milk';
$shoppingList[] = 'eggs';
$shoppingList[] = 'bread';
print_r($shoppingList);
// Array
// (
// [0] => milk
// [1] => eggs
// [2] => bread
// )
$shoppingList[] looks like indexing into nothing, but read it as its own idiom: “give this the next available index and put it there.” PHP tracks that next index for you; you never have to.
The mental model: arrays are ordered maps
Here’s the fact that makes the rest of this chapter, and the next section on associative arrays, click into place: there’s no separate “list” type in PHP. ['apple', 'banana', 'cherry'] is shorthand for [0 => 'apple', 1 => 'banana', 2 => 'cherry']: an indexed array is an associative array whose keys happen to be sequential integers starting at zero. Every PHP array, underneath, is the same ordered structure: a hash map that also remembers insertion order.
This explains behavior that otherwise looks like a quirk. Filter an indexed array and the surviving elements keep their original keys, not fresh ones:
<?php
declare(strict_types=1);
$numbers = [10, 15, 20, 25, 30];
$even = array_filter($numbers, fn (int $n) => $n % 2 === 0);
print_r($even);
// Array
// (
// [0] => 10
// [2] => 20
// [4] => 30
// )
Keys 1 and 3 are simply gone, not renumbered, because under the hood, array_filter() removed two entries from a map, and a map has no obligation to stay contiguous. If you need a clean 0, 1, 2, ... sequence afterward, array_values() re-indexes:
<?php
$reindexed = array_values($even); // [10, 20, 30]
Functions you’ll reach for constantly
A handful of functions cover most of what you do with indexed arrays day to day:
<?php
declare(strict_types=1);
$scores = [88, 92, 74, 95, 60];
array_push($scores, 100); // append (same as $scores[] = 100, but explicit)
$last = array_pop($scores); // removes and returns the last element (100)
$passing = array_filter($scores, fn (int $s) => $s >= 60);
$grades = array_map(fn (int $s) => $s >= 90 ? 'A' : 'B', $passing);
sort($scores); // sorts in place, re-indexes from 0
$hasTopScore = in_array(95, $scores, strict: true);
echo implode(', ', $grades) . "\n";
array_map() transforms every element and returns a same-length array; array_filter() keeps elements that pass a test and, as you just saw, does not reindex; sort() mutates the array in place and renumbers it, which is worth remembering if you were relying on the original keys for anything. in_array() with strict: true uses === under the hood rather than PHP’s looser default comparison, worth making a habit, for the same reason === earned its own callout back in Data Types.
array_push() and $scores[] = ... do the same thing for a single value; array_push() exists mainly because it can take several values at once and because “push” reads clearly when you’re thinking of the array as a stack. Either is fine: pick whichever reads better at the call site.
Storing UTF-8 Encoded Text with Strings
You met strings back in Hello, World!, and used interpolation without much ceremony in the guessing game. What we skipped, reasonably, was the part that eventually bites everyone who works with PHP strings: they’re not really made of characters. They’re made of bytes. Most of the time that distinction is invisible, right up until it isn’t.
Quotes, briefly revisited
A quick recap, since you’ll use both constantly: single quotes are as literal as PHP gets, with no interpolation and no escape sequences beyond \' and \\. Double quotes interpolate variables and understand escape sequences like \n and \t:
<?php
declare(strict_types=1);
$name = 'Damien';
echo 'Hello, $name\n'; // Hello, $name\n (literal, no processing)
echo "Hello, $name\n"; // Hello, Damien (interpolated, newline applied)
Reach for single quotes when a string has nothing to interpolate: it’s marginally faster (PHP doesn’t have to scan the string looking for $ or \), but mostly it signals to the next reader “nothing clever happening here.”
Bytes versus characters
Here’s the fact that matters: PHP’s classic string functions (strlen(), strtoupper(), substr(), and their relatives) operate on bytes, full stop. That was a perfectly fine assumption in an ASCII world, where one byte is one character. It falls apart the moment your text isn’t ASCII, which in a UTF-8 world (essentially all modern PHP output) is often:
<?php
declare(strict_types=1);
$name = 'café';
echo strlen($name) . "\n"; // 5, not 4!
echo mb_strlen($name) . "\n"; // 4, correct
café has four characters, but the é is encoded in UTF-8 as two bytes, so strlen(), which counts bytes, reports five. It isn’t wrong, exactly; it’s answering a question you didn’t mean to ask. mb_strlen() (the mb_ stands for multibyte) understands UTF-8 encoding and counts actual characters, which is almost always what you want when the string might contain anything beyond plain English.
The practical rule: if a string could ever contain a name, a comment, a search term, an emoji, anything a user typed, use the mb_ variant. strlen() is still fine for genuinely byte-oriented work: measuring the size of a file’s contents, or a string you built yourself out of known ASCII. When in doubt, mb_strlen() costs you nothing and saves you from a bug that only shows up for some of your users, usually the ones with accented names, which is exactly the kind of bug that’s embarrassing to ship.
Everyday string functions
A handful of functions cover the bulk of real-world string work:
<?php
declare(strict_types=1);
$message = 'PHP is not dead, it just smells funny.';
if (str_contains($message, 'not dead')) {
echo "Reassuring.\n";
}
$corrected = str_replace('not dead', 'thriving', $message);
echo $corrected . "\n";
$excerpt = substr($message, 0, 12);
echo $excerpt . "...\n"; // PHP is not d...
$formatted = sprintf('%s scored %d%% on the test.', 'Alice', 92);
echo $formatted . "\n"; // Alice scored 92% on the test.
str_contains() (PHP 8.0+) replaced the old, awkward strpos($haystack, $needle) !== false idiom you’ll still see in older code. It does exactly what its name says and returns a plain boolean, no special-case false to trip over. str_replace() swaps every occurrence of a substring. substr() extracts a portion by start position and length. Like strlen(), it has an mb_substr() counterpart that counts characters instead of bytes, worth reaching for under the same rule as above.
sprintf() deserves particular attention: it builds a formatted string from a template and a list of values, which reads far more clearly than a chain of concatenations once more than one or two values are involved, and it gives you control interpolation doesn’t: %d%% above forces 92 to be treated as an integer and prints a literal % sign afterward. printf() is the same thing, minus the “return a string” part; it prints directly instead.
Interpolation, one more time
You already know the basics from Chapter 2, but the full form is worth having on hand: {$expr} inside a double-quoted string accepts more than a bare variable, including property access, method calls, array access, anything that resolves to a value:
<?php
declare(strict_types=1);
$user = ['name' => 'Alice', 'age' => 30];
echo "{$user['name']} is {$user['age']} years old.\n";
Without the braces, "$user['name']" doesn’t do what you’d expect: PHP would stop parsing the variable name at $user and print the rest literally. The {$...} form removes that ambiguity entirely, which is why it’s worth using as your default the moment interpolation gets more complex than a single bare $variable.
Storing Keys with Associated Values in Associative Arrays
You already know, from the previous section, that there’s no real difference between an indexed array and an associative array: they’re the same underlying structure, PHP’s ordered map, and the only thing that changes is what you use as the key. An associative array is just an array where you chose the keys yourself, usually strings, instead of letting PHP assign sequential integers:
<?php
declare(strict_types=1);
$prices = [
'apple' => 0.50,
'banana' => 0.30,
'cherry' => 3.20,
];
echo $prices['banana'] . "\n"; // 0.3
$prices['date'] = 4.10; // add a new key
Keys can be strings or integers (PHP will happily mix both in the same array), but they must be unique: assign to an existing key and you overwrite the old value rather than adding a second entry.
isset() versus array_key_exists(), and the gotcha between them
Both functions answer a version of “is this key there,” and they are not interchangeable: the difference has caused real bugs, so it’s worth internalizing rather than half-remembering.
<?php
declare(strict_types=1);
$user = [
'name' => 'Alice',
'nickname' => null,
];
var_dump(isset($user['name'])); // true
var_dump(isset($user['nickname'])); // false, surprising!
var_dump(array_key_exists('nickname', $user)); // true
isset() checks whether a value exists and is not null. nickname is a real key with a real entry, but its value happens to be null, and isset() treats that exactly like “not there.” array_key_exists() doesn’t care what the value is; it only asks whether the key was ever set, null or otherwise.
This matters in practice whenever null is a meaningful value rather than an absence: a user record where “no nickname” is legitimately stored as null, say. Reach for isset() for the common case (does this exist and have a usable value), and array_key_exists() when you specifically need to distinguish “never set” from “set to null.” Mixing them up is one of those PHP surprises that costs you an hour the first time and never again after that. Trust me on this one.
Iterating with foreach
You saw foreach in Control Flow mostly on indexed arrays. On an associative array, the key-value form is where it earns its keep:
<?php
declare(strict_types=1);
$prices = [
'apple' => 0.50,
'banana' => 0.30,
'cherry' => 3.20,
];
foreach ($prices as $fruit => $price) {
echo "{$fruit}: \${$price}\n";
}
// apple: $0.5
// banana: $0.3
// cherry: $3.2
Iteration order matches insertion order, always: another direct consequence of arrays being ordered maps rather than genuinely unordered hash tables. You never have to sort an associative array just to get a predictable iteration order; it already has one.
Nesting: arrays of associative arrays
The shape you’ll meet constantly in real code is a list of records: an indexed array where each element is itself an associative array, standing in for one row of data:
<?php
declare(strict_types=1);
$books = [
['title' => 'The Pragmatic Programmer', 'author' => 'Hunt & Thomas', 'year' => 1999],
['title' => 'Refactoring', 'author' => 'Martin Fowler', 'year' => 2018],
['title' => 'Clean Code', 'author' => 'Robert C. Martin', 'year' => 2008],
];
foreach ($books as $book) {
echo "{$book['title']} ({$book['year']}): {$book['author']}\n";
}
$recent = array_filter($books, fn (array $book) => $book['year'] >= 2008);
$titles = array_map(fn (array $book) => $book['title'], $books);
echo implode(', ', $titles) . "\n";
This is exactly the shape you get back from a database query, a JSON API response decoded with json_decode($json, true), or a CSV file read row by row: a list of records, each one an associative array of fields. It looks almost too simple to call out, but it’s worth naming explicitly: by the time you reach Chapter 14 and beyond, this pattern (indexed array outside, associative array inside) is how you’ll represent most real-world data before it becomes anything more structured, like the objects from Chapter 5.
Error Handling
Things go wrong. A file isn’t there, a network call times out, a caller passes a string where an integer was promised, a division sneaks a zero into its denominator. No language design avoids this; the differences are in what happens the moment it does, and how much control you have over the answer. PHP’s answer has changed a lot over the years, and the modern version, the one this chapter teaches, is considerably better than its reputation.
Older PHP code, and there’s a great deal of it still running, is full of silent failures: warnings printed to a log nobody reads, functions that return false on error with no further explanation, scripts that limp on with half-initialized data because nothing actually stopped them. PHP 7 and 8 walked much of this back. Most things that used to fail quietly now throw real objects you can catch, inspect, and respond to deliberately, and the language draws a sharper line than it used to between “the caller did something recoverable” and “something is actually broken.”
That line is the spine of this chapter. We’ll look first at the errors that mean something is genuinely wrong (a TypeError, a call on null where an object was expected), the kind PHP represents with Error and its subclasses, and that you generally shouldn’t try to paper over. Then exceptions proper: try/catch/finally, throwing your own, and PHP’s built-in hierarchy, which is where most of your day-to-day error handling will actually live. Last, and arguably most useful in practice, a section on judgment: when to throw, when to just return null and let the caller decide, and when the right answer is to let the whole thing stop.
None of this is abstract theory you’ll set aside once the chapter ends. The CLI project in Chapter 14 leans directly on the patterns introduced here, including a custom exception you’ll define in this chapter and use again there. Read this one carefully; it’s less about syntax than about developing the instinct for where the line between “handle it” and “let it fail” actually belongs.
Unrecoverable Errors: Fatal Errors and Error
Some problems aren’t a matter of unlucky input or a missing file: they’re a matter of your code being wrong. You called a method that doesn’t exist. You passed a string where a function demanded an integer, with strict types on. You divided by zero. None of these are things a well-behaved program should be designed to “handle” gracefully, because there’s nothing sensible to do in response except fix the bug.
PHP represents this category with the Error class and its subclasses. A few you’ll meet constantly:
<?php
declare(strict_types=1);
function half(int $n): int
{
return $n / 0; // DivisionByZeroError
}
function double(int $n): int
{
return $n * 2;
}
double("four"); // TypeError: strict_types is on, no silent conversion
$user = null;
$user->getName(); // Error: Call to a member function getName() on null
DivisionByZeroError, TypeError, and the generic Error you get from calling a method on null are all doing the same job: telling you, as precisely as possible, that the program reached a state it has no business being in. This is exactly the kind of situation declare(strict_types=1) was designed to surface loudly rather than let slide; you met the mechanism in Data Types, and this is where it pays off.
Why this used to be worse
If you look at PHP code written before version 7, you’ll find a lot of manual null checks and is_int() guards scattered defensively through function bodies, because back then, many of these situations didn’t throw anything catchable at all. Calling a method on null was a fatal error that simply halted the script, full stop, no try/catch in the world could intervene. A type mismatch might silently coerce, or emit a warning to a log nobody was watching, and keep going with garbage data.
PHP 7 introduced Error (and PHP 8 sharpened it further) specifically to fix this: nearly everything in this category is now a real object implementing Throwable, the same interface Exception implements. That means you technically can write catch (Error $e) and keep your program running. It also means you very often shouldn’t.
Catchable doesn’t mean “should catch”
The distinction that matters here isn’t “can PHP represent this as an object” (as of PHP 8, it almost always can); it’s “does catching this actually fix anything.” Compare:
<?php
declare(strict_types=1);
// Reasonable: the input is genuinely unpredictable, and there's a sensible fallback.
try {
$config = json_decode($configJson, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$config = [];
}
// Unreasonable: papering over a bug instead of fixing it.
try {
$total = $order->getTotal(); // $order might be null due to a bug upstream
} catch (\Error $e) {
$total = 0; // now every bug in this code path just... returns zero, silently
}
The first case catches a genuinely recoverable situation: malformed JSON from an external source is a normal thing to happen, and falling back to an empty config is a defensible choice. The second case catches a symptom of a bug and hides it behind a plausible-looking number. Six months later, someone is debugging why totals are occasionally zero, with no exception, no log entry, no clue, because the catch block ate the only evidence that something went wrong.
The rule worth keeping: catch Error and its subclasses only when you have a genuinely good reason and a narrow, specific type; never a broad catch (\Error $e) used as a safety net. If your own code is producing a TypeError, the fix is almost always to correct the code that’s calling the function wrong, not to wrap the call in a try block. We’ll draw this line more precisely in To Throw or Not to Throw; for now, treat Error as PHP telling you something is broken, and exceptions, covered next, as PHP telling you something needs a decision.
Recoverable Errors with Exceptions
Where the previous section was about bugs, this one is about situations your code should genuinely expect: a file that might not exist, an age that might be negative, an API that might reject the request you sent it. None of these mean your program is broken; they mean it needs to make a decision, and exceptions are PHP’s mechanism for saying “here’s a problem, and here’s what I know about it” up the call stack to whoever is equipped to decide what happens next.
try, catch, finally
The shape is the same as in most languages with exceptions:
<?php
declare(strict_types=1);
function readConfig(string $path): array
{
if (!file_exists($path)) {
throw new \RuntimeException("Config file not found: {$path}");
}
return json_decode(file_get_contents($path), associative: true);
}
try {
$config = readConfig('config.json');
echo "Loaded " . count($config) . " settings.\n";
} catch (\RuntimeException $e) {
echo "Couldn't load config: {$e->getMessage()}\n";
$config = [];
} finally {
echo "Config load attempt finished.\n";
}
throw raises an exception and immediately stops normal execution: nothing after the throw in readConfig() runs. Control jumps to the nearest enclosing catch block whose type matches, skipping everything in between, however many function calls deep that turns out to be. finally runs regardless of what happened (exception caught, exception not caught, or no exception at all), which makes it the right place for cleanup that has to happen no matter what: closing a file handle, releasing a lock.
Exception versus Error, and Throwable
PHP’s exception hierarchy has two parallel branches, both implementing the same interface, Throwable:
Exceptionand its subclasses: for conditions a well-written program can reasonably anticipate and recover from.InvalidArgumentException,RuntimeException,JsonException.Errorand its subclasses (covered in the previous section): for things that indicate a bug:TypeError,DivisionByZeroError.
Throwable is the interface both branches implement, and it’s what lets catch blocks be precise: catch (\Exception $e) catches exceptions but lets an Error propagate past it; catch (\Throwable $e) catches either. Reach for \Throwable only at the very edges of an application (a top-level handler that logs anything unhandled before the process exits), never as a routine catch type in ordinary business logic. Catching it casually is how bugs quietly turn into “handled” cases that never get fixed.
Catching several types at once
A single catch can list multiple types separated by |, when you genuinely want to handle more than one the same way:
<?php
declare(strict_types=1);
try {
$result = $client->send($request);
} catch (ConnectionException|TimeoutException $e) {
echo "Network problem, retrying: {$e->getMessage()}\n";
$result = retry($request);
}
If the handling logic actually differs between the two, use two separate catch blocks instead. Combining types is for when the response really is identical, not a shortcut to avoid writing a second block.
Writing your own exception
Built-in exceptions like RuntimeException and InvalidArgumentException cover a lot of ground, but naming your own is one of the most common things you’ll do in real PHP code: a specific exception type tells the caller precisely what went wrong, and lets them catch just that, instead of guessing from a string message:
<?php
declare(strict_types=1);
class InvalidAgeException extends \Exception
{
public function __construct(
public readonly int $age,
) {
parent::__construct("Invalid age: {$age}. Must be between 0 and 150.");
}
}
function registerUser(string $name, int $age): void
{
if ($age < 0 || $age > 150) {
throw new InvalidAgeException($age);
}
echo "Registered {$name}, age {$age}.\n";
}
try {
registerUser('Alice', -5);
} catch (InvalidAgeException $e) {
echo "Registration failed: {$e->getMessage()}\n";
echo "Offending value was: {$e->age}\n";
}
Extending \Exception gets you the whole standard machinery for free: getMessage(), getCode(), getPrevious(), a stack trace via getTraceAsString(). Calling parent::__construct() is what actually wires your custom message into that machinery; skip it and getMessage() comes back empty. Beyond that, the class is yours to shape: InvalidAgeException above stores the offending $age as a readonly property, so a catch block gets structured data to work with, not just a string to parse. This exact pattern (a small, specific exception class carrying the context that caused it) comes up again in Chapter 14, so it’s worth being genuinely comfortable with it here.
To Throw or Not to Throw
Knowing the syntax of try/catch is the easy part. The harder question, and the one that actually separates readable PHP code from a maze of defensive checks, is deciding when a function should throw, when it should just return null or false or an empty array, and when it’s fine to let the whole thing come crashing down. There’s no compiler rule for this: it’s judgment, the kind you build from having been burned both ways. Here’s how I’ve come to think about it.
Not found is not exceptional
The most common mistake I see is throwing for something that isn’t actually exceptional: it’s just a normal outcome the caller needs to handle. Looking up a user by an ID that doesn’t exist isn’t a crisis. It’s a completely ordinary thing to happen, as routine as any other branch in your code:
<?php
declare(strict_types=1);
function findUserById(array $users, int $id): ?array
{
foreach ($users as $user) {
if ($user['id'] === $id) {
return $user;
}
}
return null; // not found, a completely normal outcome, not an error
}
$user = findUserById($users, 42);
if ($user === null) {
echo "No such user.\n";
} else {
echo "Found: {$user['name']}\n";
}
Returning null here (and giving the function a ?array return type so the possibility is visible right in the signature, not just implied) tells the caller exactly what to expect and lets them decide what “not found” means in their context: show a 404, create a default, ask again. Throwing UserNotFoundException instead would force every caller into a try/catch for something that’s going to happen constantly and isn’t wrong in any sense. Save exceptions for things that are actually exceptions.
Throw when the caller has a precondition to meet
The flip side: throw when something the caller was supposed to guarantee didn’t hold, and there’s genuinely no reasonable default to fall back to. This is the InvalidAgeException from the previous section: a negative age isn’t “a normal outcome to branch on,” it’s a violated contract. The function can’t sensibly guess what you meant, so it says so, loudly and specifically:
<?php
declare(strict_types=1);
function withdraw(float $balance, float $amount): float
{
if ($amount > $balance) {
throw new \RuntimeException(
"Cannot withdraw {$amount}: balance is only {$balance}."
);
}
return $balance - $amount;
}
Silently clamping the withdrawal to the available balance, or quietly returning 0, would hide a bug (or worse, a real financial error) behind a plausible-looking number, exactly the failure mode from the previous section’s catch (\Error $e) example. Throwing here forces whoever’s calling withdraw() to actually confront the situation instead of it slipping past unnoticed.
Let it crash when it’s a bug, not a case
Sometimes the right answer isn’t null and isn’t a caught exception: it’s letting the program stop. If your own code calls a function with the wrong argument type, or reaches a match arm that should be logically impossible, that’s not a runtime condition to design around; it’s a bug to fix, and pretending otherwise just buries the evidence:
<?php
declare(strict_types=1);
enum Status
{
case Draft;
case Published;
case Archived;
}
function statusLabel(Status $status): string
{
return match ($status) {
Status::Draft => 'Draft',
Status::Published => 'Published',
Status::Archived => 'Archived',
};
}
match without a default arm throws UnhandledMatchError (a subclass of Error, not Exception) if none of the cases fit. For an enum, every case is already covered, so this can only happen if someone adds a new Status case later and forgets to update this function. That’s exactly the kind of failure you want loud and immediate at the point of the bug, not silently swallowed three files away. Don’t wrap this in a try/catch “just in case”: let it fail, let the stack trace point straight at the missing arm, and go fix statusLabel().
A rough decision order
When you’re not sure which of the three to reach for, this order has served me well:
- Is “not found” or “empty” a normal, expected outcome here? Return
null,false, or an empty array, and give the function a return type that makes the possibility explicit (?array, notarray). - Did the caller violate a precondition, with no sensible default to fall back to? Throw a specific exception: built-in if one fits (
InvalidArgumentException,RuntimeException), a small custom class if the caller needs structured context back, as withInvalidAgeException. - Is this actually impossible unless the code itself is wrong? Don’t defend against it at all. Let PHP’s own
Errormachinery do its job, or useassert()during development. A loud, immediate failure at the site of the bug is far cheaper to fix than a quiet one three layers ofcatchaway.
None of this is a rule you can apply mechanically: plenty of real code sits in a gray area between “expected” and “precondition violated,” and reasonable developers land in different places. But asking the question explicitly, function by function, beats defaulting to whichever of throw or return null you happened to type first. It’s a habit worth building deliberately, because you’ll be making this exact call in nearly every function you write from here on, including several in the CLI project starting at Chapter 14.
Web Development Basics
Every program this book has built so far has run on the command line: you typed something, it printed something back, and that was the whole interaction. PHP started life as a web language, though, and most PHP running today still is: pages served to a browser in response to a request that arrives over HTTP instead of as arguments on a command line. This chapter is where that side of PHP finally gets its due.
You’ll build a small guestbook: a page with a form for a name and a message, a script that reads what was submitted, checks it for problems, stores it, and lists everything anyone’s written so far. Three sections, each adding one layer. First, getting data out of a submitted form and into your script at all, using the superglobal arrays PHP fills in for you. Then, making sure what your script sends back to the browser doesn’t hand a visitor a way to attack other visitors. Last, keeping the guestbook’s messages around between requests instead of losing them the moment the response finishes, by talking to an actual database.
Deliberately, none of it will look impressive. No JavaScript framework, no CSS framework, no build step: a single HTML <form>, a few lines of inline styling, and PHP itself doing the rest, served straight off php -S, the same built-in development server the book’s final project uses later on. Keeping the surrounding technology plain is the point. The lesson here is what PHP does with a request, not how to configure a bundler.
This groundwork gets reused directly later in the book, once the final project arrives: reading $_SERVER, escaping output before it reaches HTML, storing data safely, all of it is the ordinary substance of writing PHP for the web, worth seeing on its own, in the smallest form that could possibly matter, before a router and a class hierarchy show up around it.
Accepting Input with HTML Forms and Superglobals
PHP has no special syntax for “this script is a web page.” What it has instead is a handful of arrays that PHP fills in for you before a single line of your code runs, populated from whatever the browser sent along with the request. Those arrays are called superglobals, and they’re available in every scope without needing global or a parameter: no importing, no passing them around, just there.
A plain HTML form
Start with the form itself. Create guestbook.php:
<!DOCTYPE html>
<html>
<head>
<title>Guestbook</title>
<style>
body { font-family: sans-serif; max-width: 40em; margin: 2em auto; }
textarea { width: 100%; }
</style>
</head>
<body>
<h1>Guestbook</h1>
<form method="post">
<p><label>Name: <input type="text" name="name"></label></p>
<p><label>Message: <textarea name="message"></textarea></label></p>
<p><button type="submit">Sign the guestbook</button></p>
</form>
</body>
</html>
Nothing here is PHP yet: it’s a <form> with method="post" and no action attribute, which means submitting it sends a POST request back to this same URL. method="get" is the other common choice, and the difference matters: a GET request encodes its data right in the URL (?name=Alice), visible in the address bar and in server logs, fine for a search box, wrong for anything sensitive or anything that changes data. A guestbook entry is exactly the kind of thing that belongs in a POST body instead.
Serve it with PHP’s built-in development server:
$ php -S localhost:8000
Visit http://localhost:8000 and the form renders, but submitting it does nothing yet: the same page reloads, and whatever you typed is gone. Reading what was submitted is PHP’s job, and it hasn’t been asked to do it yet.
Superglobals: $_GET, $_POST, $_SERVER
Three superglobals matter most for a script like this one:
$_GET: an associative array of query-string parameters, populated for any request, but conventionally read onGETrequests.$_POST: an associative array of the form fields submitted in aPOSTrequest’s body.$_SERVER: information about the request and the server itself.$_SERVER['REQUEST_METHOD']('GET'or'POST') is what lets one script handle both showing a blank form and processing a submitted one.
There’s also $_REQUEST, which merges $_GET, $_POST, and cookie data together. It’s convenient and best avoided: your script ends up unable to tell whether a value arrived in the URL or the request body, which matters more than it sounds like it should once security is on the table, in the next section.
Reading the submission
Add PHP to the top of guestbook.php, before the <!DOCTYPE html> line:
<?php
$name = '';
$message = '';
$submitted = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = $_POST['name'] ?? '';
$message = $_POST['message'] ?? '';
$submitted = true;
}
?>
The null coalescing operator ?? (from Chapter 3) covers the case where a field is missing entirely: a request forged by hand, or a browser quirk, shouldn’t produce an “undefined array key” warning. Then, further down the file, show the submission when there is one:
<body>
<h1>Guestbook</h1>
<?php if ($submitted) { ?>
<p>Thanks, <?= $name ?>. You wrote: <?= $message ?></p>
<?php } ?>
<form method="post">
Reload, fill in the form, and submit it: the page now greets you back with exactly what you typed. Try curl instead of a browser, to see the request itself:
$ curl -X POST -d "name=Alice&message=Hello there" http://localhost:8000/
The response includes Thanks, Alice. You wrote: Hello there, the same greeting, built entirely from $_POST.
The problem you can already see coming
That last echo, by way of <?= ?>, prints $name and $message straight into the page, completely unfiltered. Try submitting <b>bold</b> as your name. It renders as bold text, not literal angle brackets, which means the guestbook is currently willing to run any HTML a visitor types, not just yours. The next section deals with exactly that, before anything gets stored anywhere permanent.
Validating Input and Preventing Cross-Site Scripting
The previous section left off with a script that prints $_POST values straight into HTML, and a demonstration that this lets a visitor’s browser render arbitrary markup. Pushed a little further, that’s not just an oddity: submit <script>alert('hello from your own guestbook')</script> as a message, and the browser executes it. That’s cross-site scripting, XSS for short: an attacker gets their own JavaScript to run in your page, in your visitors’ browsers, under your site’s own trust. A guestbook that stores and redisplays messages is a textbook place for it to happen, which makes it a good place to learn to stop it.
Escaping output
The fix isn’t to reject angle brackets outright: it’s to make sure any user-supplied text that ends up inside HTML is escaped first, so a browser displays it as text rather than parsing it as markup. PHP’s tool for this is htmlspecialchars(), which converts the characters that matter to an HTML parser (<, >, &, and quotes) into their entity equivalents (<, >, &, and so on):
<?php if ($submitted) { ?>
<p>Thanks, <?= htmlspecialchars($name) ?>. You wrote: <?= htmlspecialchars($message) ?></p>
<?php } ?>
Submit <script>...</script> again, and the page now shows the literal text <script>alert('hello from your own guestbook')</script> instead of running it. Since PHP 8.1, htmlspecialchars() defaults to escaping quotes as well as angle brackets, which is what you want almost every time: the rule worth keeping is simple. Any value that came from outside your script, printed anywhere inside HTML, goes through htmlspecialchars() first, with no exceptions carved out for values that “probably” are safe. A name field looks harmless right up until someone tests it with a <script> tag.
Validating before you trust the data at all
Escaping protects the output. Validation is a separate concern: deciding whether the input is even acceptable before your script does anything with it. Expand the form handling to check for problems and collect them:
<?php
$name = '';
$message = '';
$submitted = false;
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$message = trim($_POST['message'] ?? '');
$submitted = true;
if ($name === '') {
$errors[] = 'Name cannot be empty.';
} elseif (mb_strlen($name) > 60) {
$errors[] = 'Name is too long.';
}
if ($message === '') {
$errors[] = 'Message cannot be empty.';
} elseif (mb_strlen($message) > 500) {
$errors[] = 'Message is too long.';
}
}
?>
trim() clears leading and trailing whitespace, so a message that’s nothing but spaces doesn’t slip through the empty check. mb_strlen(), rather than plain strlen(), counts characters rather than bytes, which matters the moment a visitor’s name includes anything outside plain ASCII: the same UTF-8 concern Chapter 8 covered for strings generally applies here. Errors accumulate in an array instead of stopping at the first one, so a visitor sees every problem at once rather than fixing them one submission at a time.
Show the errors, and redisplay the submitted values (escaped, same as before) so nobody has to retype a long message just because their name was too short:
<?php if ($errors) { ?>
<ul>
<?php foreach ($errors as $error) { ?>
<li><?= htmlspecialchars($error) ?></li>
<?php } ?>
</ul>
<?php } elseif ($submitted) { ?>
<p>Thanks, <?= htmlspecialchars($name) ?>. You wrote: <?= htmlspecialchars($message) ?></p>
<?php } ?>
<form method="post">
<p><label>Name: <input type="text" name="name" value="<?= htmlspecialchars($name) ?>"></label></p>
<p><label>Message: <textarea name="message"><?= htmlspecialchars($message) ?></textarea></label></p>
<p><button type="submit">Sign the guestbook</button></p>
</form>
Notice value="<?= htmlspecialchars($name) ?>" inside the <input> tag: escaping matters just as much inside an HTML attribute as it does in the page body, since a stray " in an unescaped value would let a visitor break out of the attribute and inject their own.
A related risk worth naming
XSS is about a visitor’s browser running an attacker’s script inside your page. A different, related risk is CSRF, cross-site request forgery, where a different site tricks a visitor’s browser into submitting a form to your site on their behalf, using whatever session they’re already logged into. Defending against it properly, typically a hidden token generated per form and checked on submission, is beyond what this small guestbook needs, but it’s worth knowing the term exists for the day you’re building something where a forged submission would actually matter.
The guestbook now behaves safely for a single request: it validates what comes in, and escapes what goes back out. What it still doesn’t do is remember anything. Reload the page and every message is gone, because nothing has been stored anywhere. That’s next.
Talking to a Database with PDO
Every request to guestbook.php starts from nothing: PHP, in its classic and still most common form, gives each incoming request a fresh start, running the script from the top and throwing everything away once the response is sent, variables included. Nothing carries over from the last request except what was deliberately saved somewhere. So far, nothing has been. To make messages outlive the request that submitted them, they need to live somewhere PHP can read them back later: a database. (Chapter 18 covers this shared-nothing request model properly, including why it means PHP rarely needs threads.)
PDO and SQLite
PHP talks to databases through several extensions, but PDO, the PHP Data Objects extension, is worth reaching for first: it gives you one consistent interface across different database engines, so the same code style works whether the data underneath is MySQL, PostgreSQL, or, as here, SQLite. SQLite stores an entire database as a single ordinary file, with no separate server process to install or configure, which makes it the right choice for keeping this chapter’s “connected technologies” as simple as the PHP itself.
Open a connection near the top of guestbook.php:
<?php
$pdo = new PDO('sqlite:' . __DIR__ . '/guestbook.db');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
)
');
'sqlite:' . __DIR__ . '/guestbook.db' is a DSN, a data source name, telling PDO which driver to use and where the database lives; the file is created automatically the first time this runs if it doesn’t exist yet. PDO::ATTR_ERRMODE set to PDO::ERRMODE_EXCEPTION is worth setting every time: without it, PDO fails some operations silently, returning false instead of raising anything, which is exactly the kind of quiet failure Chapter 9 warned against. With it, a bad query throws a PDOException, catchable like any other. CREATE TABLE IF NOT EXISTS means this line is safe to leave in the script and run on every single request: it does nothing once the table already exists.
The wrong way to build a query
Before writing the insert, look at the version to avoid:
// Don't do this.
$pdo->exec("INSERT INTO entries (name, message, created_at) VALUES ('$name', '$message', '" . date('c') . "')");
If $message contains a single quote followed by SQL of an attacker’s choosing, that SQL becomes part of the query PHP actually runs: a classic SQL injection, in the same family of bug as the XSS from the previous section, just aimed at your database instead of a visitor’s browser. String-building a query out of untrusted values is never safe, no matter how carefully the string looks assembled.
Prepared statements
PDO’s real answer is a prepared statement: the query’s structure is sent to the database first, with placeholders standing in for values, and the actual values are sent separately afterward. The database never treats a value as part of the query’s syntax, which closes off injection entirely:
if ($submitted && !$errors) {
$statement = $pdo->prepare(
'INSERT INTO entries (name, message, created_at) VALUES (:name, :message, :created_at)'
);
$statement->execute([
'name' => $name,
'message' => $message,
'created_at' => date('c'),
]);
}
:name, :message, and :created_at are named placeholders; execute() takes an associative array matching each placeholder to its value. prepare() builds the statement once, execute() runs it with a specific set of values, and PDO handles quoting and escaping correctly for whatever database is underneath, which is exactly the part that’s easy to get wrong by hand.
Listing what’s been said so far
Reading the entries back uses the same prepare()-and-execute() shape, or, for a query with no values to insert, the simpler query():
$entries = $pdo->query('SELECT name, message, created_at FROM entries ORDER BY id DESC')
->fetchAll(PDO::FETCH_ASSOC);
fetchAll(PDO::FETCH_ASSOC) returns every row as an array of associative arrays, one per row, each key matching a column name: the same shape of data Chapter 8 already showed you how to work with. Loop over it in the HTML, escaping each value exactly as before:
<h2>Previous entries</h2>
<ul>
<?php foreach ($entries as $entry) { ?>
<li>
<strong><?= htmlspecialchars($entry['name']) ?></strong>:
<?= htmlspecialchars($entry['message']) ?>
<em>(<?= htmlspecialchars($entry['created_at']) ?>)</em>
</li>
<?php } ?>
</ul>
Escaping still applies here, and for the same reason as before: these values came from a visitor, by way of the database, and the database doesn’t know or care whether they’re safe to print as HTML. Storing a value safely and displaying it safely are two separate jobs, and skipping either one reopens exactly the hole the last section closed.
What you’ve built
Reload the guestbook, sign it a few times, and restart php -S entirely: the entries are still there, because they never lived in memory in the first place, just in guestbook.db, on disk, independent of any one request. That’s the whole shape of a real, if tiny, web application: accept input through superglobals, validate it, escape it on the way back out, and persist it safely through prepared statements. The final project, next, builds something structurally larger on the same foundation: more routes, real controller classes, a proper view layer, but nothing about the underlying ideas changes. You’ve already done the part that actually matters.
Interfaces, Traits, and Generic-Style Code
Classes give you a template for building objects. Interfaces and traits are the two tools PHP offers for the problem that shows up the moment you have more than one class: how do unrelated pieces of code agree to work together, and how do you avoid retyping the same method five times across five classes that have nothing else in common.
They solve opposite halves of that problem, and it’s worth saying so plainly up front because beginners, and more than a few experienced developers coming from other languages, routinely conflate them. An interface is a contract: it says “any class claiming this name promises to have these methods,” and says nothing whatsoever about how those methods are implemented. A trait is the reverse: it’s a literal chunk of implementation, copied wholesale into whichever classes ask for it, and it makes no promise about what those classes are or how they relate to one another. One is a shape you agree to fit. The other is a piece of code you borrow.
This chapter also faces something honest about PHP that surprises people arriving from Java, C#, or TypeScript: PHP has no true generics. You cannot write a Collection<Product> and have the language itself refuse to let a Banana sneak in. What PHP has instead is a well-worn convention, docblocks read by static analysis tools, that gets you most of the same safety, enforced not by the PHP runtime but by a separate program you run before you ship.
By the end of this chapter you’ll know when to reach for an interface, when a trait is actually the right tool, and how to write PHP that behaves, for practical purposes, as if it had generics, even though, strictly speaking, it doesn’t.
Defining Shared Behavior with Interfaces
Suppose you’re writing something that needs to print a human-readable summary of an object: an invoice line, a product, a log entry, whatever it happens to be that week. You could give every class you write a describe() method and hope everyone remembers the naming convention. Or you could make it a rule the language itself checks. That’s what an interface is for.
<?php
interface Formattable
{
public function format(): string;
}
An interface looks like a class with all the bodies removed. format(): string here is a signature, not an implementation: no braces, no logic, just a promise. Any class that says it implements Formattable must have a public format() method that returns a string. PHP enforces this at the language level. Leave the method out, or return the wrong type, and your code won’t run.
Implementing it
A class opts in with implements:
<?php
readonly class Product
{
public function __construct(
public string $name,
public float $price,
) {
}
}
readonly class InvoiceLine implements Formattable
{
public function __construct(
private Product $product,
private int $quantity,
) {
}
public function format(): string
{
$total = $this->product->price * $this->quantity;
return sprintf('%dx %s, $%.2f', $this->quantity, $this->product->name, $total);
}
}
InvoiceLine implements Formattable is a claim PHP will verify for you: if format() were missing, or typed to return an int, you’d get a fatal error the moment PHP tried to load the class, not buried three calls deep in production. A class can implement more than one interface, separated by commas, which is one of the ways PHP works around not having multiple inheritance for classes.
Why bother: programming against the interface
Here’s the part that actually pays for itself. Write a function that type-hints the interface, not the concrete class:
<?php
function printSummary(Formattable $item): void
{
echo $item->format() . "\n";
}
printSummary(new InvoiceLine(new Product('Keyboard', 49.90), 2));
printSummary() doesn’t know or care that it received an InvoiceLine. It only knows it received something that can format(). Add a second class tomorrow (Refund, Discount, ShippingFee, whatever), implement Formattable on it, and printSummary() needs no changes at all. It already works, because it was never written against a specific class in the first place.
This matters even more once tests enter the picture. If printSummary() had type-hinted InvoiceLine directly, testing it in isolation would mean constructing a real InvoiceLine with a real Product behind it. Type-hint Formattable instead, and a test can hand it any object that satisfies the contract, including a deliberately fake one built just for the test, with no Product in sight. We’ll put that to direct use once we reach Chapter 12.
instanceof
Occasionally you need to ask, at runtime, whether an object satisfies an interface:
<?php
if ($item instanceof Formattable) {
echo $item->format() . "\n";
}
Reach for this rarely. If you find yourself writing a lot of instanceof checks before calling a method, that’s usually a sign the method belongs on an interface you should be type-hinting against instead, not a sign you need more instanceof.
A note on naming
PHP has no special syntax to mark an interface as “just” a contract versus something more structural: Formattable, Countable, Stringable, ArrayAccess are all ordinary interfaces, some built into the language itself, some yours. Convention favors an adjective ending in -able for a single-capability contract (Formattable, Comparable, Sortable), which signals intent to the next reader even though PHP itself doesn’t require it. We’ll meet several of PHP’s own built-in interfaces later, in Chapter 20.
Reusing Code with PHP Traits
An interface, as you just saw, promises nothing about implementation: it’s pure shape. A trait is the opposite kind of tool, and it’s worth being blunt about the difference because the two get confused constantly: a trait is a chunk of actual method bodies that PHP pastes into a class for you, as if you’d typed the code directly inside it. There’s no contract, no polymorphism, no “any of these classes can be used interchangeably.” It’s copy-paste, formalized and made safe by the language.
Say two completely unrelated classes (a PaymentProcessor and a ReportGenerator) both want to write timestamped messages somewhere. They share no parent class, and shouldn’t; they’re not the same kind of thing. But they want the same three lines of logging code.
<?php
trait LoggableTrait
{
private array $log = [];
public function log(string $message): void
{
$this->log[] = sprintf('[%s] %s', date('H:i:s'), $message);
}
public function getLog(): array
{
return $this->log;
}
}
trait looks like a class, but you can never write new LoggableTrait(): a trait isn’t a type, and it doesn’t appear anywhere in instanceof checks or type hints. It exists purely to be pulled into other classes with use:
<?php
class PaymentProcessor
{
use LoggableTrait;
public function charge(float $amount): void
{
$this->log("Charging \${$amount}");
}
}
class ReportGenerator
{
use LoggableTrait;
public function generate(): void
{
$this->log('Generating monthly report');
}
}
$processor = new PaymentProcessor();
$processor->charge(42.00);
var_dump($processor->getLog());
// array(1) { [0]=> string(...) "[14:32:01] Charging $42" }
PaymentProcessor and ReportGenerator now both have a working log() method, a getLog() method, and a private $log property, none of which either class wrote. As far as PHP is concerned, once use LoggableTrait; runs, it’s exactly as if you’d typed those three members directly into the class body. Crucially, $processor instanceof LoggableTrait isn’t even valid: a trait grants behavior, not identity. PaymentProcessor and ReportGenerator remain two unrelated classes that happen to share some code, not siblings in a type hierarchy.
Why not just use inheritance?
Because these classes have nothing else in common. Forcing PaymentProcessor and ReportGenerator to extend some shared LoggableBase class purely to get a log() method would be modeling a relationship that doesn’t exist: a payment processor is not a kind of report generator’s parent, and PHP only gives you one parent class per class anyway, so you’d be spending your one shot on logging. A trait sidesteps the whole question: it’s not “is-a,” it’s “has this behavior, borrowed from here.”
Conflicts between traits
A class can use more than one trait at a time, and if two traits happen to define a method with the same name, PHP won’t guess which one you meant; it raises a fatal error unless you resolve it explicitly:
<?php
class Report
{
use LoggableTrait, TimestampableTrait {
LoggableTrait::log insteadof TimestampableTrait;
TimestampableTrait::log as logTimestampOnly;
}
}
insteadof picks a winner when two traits collide; as gives the loser’s version a new name instead of discarding it. You won’t need this often: most traits are narrow and purpose-built enough that collisions are rare, but it’s worth knowing the syntax exists so a codebase that uses it doesn’t read as mysterious the first time you meet it.
Naming convention
You’ll see traits named both Loggable and LoggableTrait in the wild; this book suffixes with Trait to keep them visually distinct from the interfaces they often accompany: it’s common to pair a Loggable interface (the contract: “this class can log”) with a LoggableTrait (the shared implementation that satisfies it), which is arguably traits’ best use case in real code. Neither convention is enforced by PHP itself; pick one for a codebase and stay consistent.
Generic-Style Code with Docblocks and Static Analysis
Here’s something to say plainly, because plenty of documentation dances around it: PHP does not have generics. In a language that does (Java’s List<String>, TypeScript’s Array<Product>), the compiler itself refuses to let the wrong type into a typed container. PHP’s type system stops at the array boundary. You can type-hint a parameter as array, but “an array of what” is not something the language will check for you, ever, at runtime.
<?php
function totalPrice(array $products): float
{
$total = 0.0;
foreach ($products as $product) {
$total += $product->price;
}
return $total;
}
Nothing here stops you from calling totalPrice([1, 2, 3]) or totalPrice(['not', 'products']). PHP will happily run the loop and blow up on $product->price the moment it hits something that isn’t an object with a price property, at runtime, in production if you’re unlucky, instead of the moment you wrote the bug.
The workaround: docblocks static analysis tools understand
The PHP ecosystem’s answer isn’t a language feature; it’s a convention. You annotate what an array actually contains in a docblock comment, and a separate tool, run before you ship, checks that annotation against how the code is actually used.
<?php
/**
* @param Product[] $products
*/
function totalPrice(array $products): float
{
$total = 0.0;
foreach ($products as $product) {
$total += $product->price;
}
return $total;
}
@param Product[] $products means nothing to the PHP interpreter; it’s a comment, and php totalPrice.php runs identically with or without it. What it means something to is PHPStan or Psalm, the two dominant static analysis tools in the PHP world. Run one of them against this file, and it will trace every call site: if some other function passes an array containing an int, or a Refund object instead of a Product, the analyzer flags it: the same category of error a generics-checking compiler would catch, just caught by a separate program instead of the language itself.
@template: closer to real generics
For genuinely generic structures (a collection class that could hold any single type, consistently), both tools understand a more expressive annotation modeled directly on how generics read in other languages:
<?php
/**
* @template T
*/
final class TypedCollection
{
/** @var T[] */
private array $items = [];
/**
* @param T $item
*/
public function add(mixed $item): void
{
$this->items[] = $item;
}
/**
* @return T[]
*/
public function all(): array
{
return $this->items;
}
}
Used with a matching @var annotation at the call site:
<?php
/** @var TypedCollection<Product> $products */
$products = new TypedCollection();
$products->add(new Product('Keyboard', 49.90));
PHPStan will track T as Product through the rest of that variable’s life, and complain the moment you add() something that isn’t one. mixed in the actual method signature is doing the honest work here: it’s what PHP itself sees and permits at runtime, anything at all. The @template T annotation is the layer above it, understood only by the analyzer, that narrows mixed down to something specific for as long as static analysis is watching.
Where this leaves you
This isn’t a workaround you should feel apologetic about; it’s simply how PHP’s type system works today, and the ecosystem has settled comfortably around it. Real projects run PHPStan or Psalm as a required step in CI, often at a strict analysis level, and treat a docblock type mismatch exactly like a compiler error: something that fails the build, not a suggestion. The runtime stays permissive by design (that’s a PHP trait as old as the language), but nothing forces you to ship code that only the runtime has checked. Add these annotations wherever an array parameter’s contents matter, install one of the two tools, and you get most of what a generics-checking language gives you, just delivered a step earlier in your workflow instead of built into php itself.
Writing Automated Tests
Every piece of code in this book so far has been verified the same way: you ran it and looked at the output. That works fine for a guessing game. It stops working the moment your project has more than a handful of functions, because you can no longer hold “everything that might have broken” in your head every time you change a line. Automated tests are how you outsource that job to the computer, which never gets tired of running the same check for the thousandth time and never forgets to run it at all.
PHP’s testing ecosystem has one clear default: PHPUnit. It’s been the de facto standard for close to two decades, it’s what nearly every library and framework in the PHP world uses internally, and it’s the tool this chapter will teach. You already know how to pull it into a project: it’s a Composer package, installed the same way you met in Chapter 7.
This chapter covers three things in order: how to actually write a test and the assertions PHPUnit gives you to make claims about your code’s behavior; how to control which tests run and when, once you have more than a handful of them; and how to organize a growing test suite so it stays navigable instead of becoming its own maintenance burden. By the end, testing won’t be an afterthought bolted onto finished code; it’ll be part of how you write the code in the first place, which is exactly the habit Chapter 14 leans on when it builds a small project test-first.
How to Write Tests with PHPUnit
Start a small project the way you did in Chapter 7:
$ composer init --no-interaction
$ composer require --dev phpunit/phpunit
That --dev matters: PHPUnit is a tool you use while building the project, not something the project needs to run in production. Composer keeps development-only dependencies separate for exactly this reason: they never ship.
The code under test
Here’s a small class worth testing, the kind of thing you’ve been writing since Chapter 5:
<?php
// src/Rectangle.php
declare(strict_types=1);
final class Rectangle
{
public function __construct(
private readonly float $width,
private readonly float $height,
) {
}
public function area(): float
{
return $this->width * $this->height;
}
public function isSquare(): bool
{
return $this->width === $this->height;
}
}
Nothing new here: a readonly class with two properties and two methods. The question a test answers is simple: does it actually do what it claims to?
Your first test
A test is a class extending PHPUnit’s TestCase, with methods whose names start with test:
<?php
// tests/RectangleTest.php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class RectangleTest extends TestCase
{
public function testAreaOfARectangle(): void
{
$rectangle = new Rectangle(8.0, 7.0);
$this->assertEquals(56.0, $rectangle->area());
}
}
assertEquals(expected, actual) is the workhorse assertion: it fails the test, with a readable diff, if the two values aren’t equal. Run the suite:
$ vendor/bin/phpunit tests
PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.012, Memory: 6.00 MB
OK (1 test, 1 assertion)
One dot per passing test. That’s it: that’s the whole feedback loop you’ll live in for the rest of this chapter.
#[Test] as an alternative to the test prefix
PHP 8 attributes (covered properly in Chapter 20) give PHPUnit a second way to mark a method as a test, without the naming constraint:
<?php
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RectangleTest extends TestCase
{
#[Test]
public function itCalculatesArea(): void
{
$rectangle = new Rectangle(8.0, 7.0);
$this->assertEquals(56.0, $rectangle->area());
}
}
Either style is fine, and most projects settle on one and stay consistent. This book will keep using the test prefix, since it needs no use statement and reads clearly enough on its own.
More assertions: assertTrue, and assertEquals vs. assertSame
<?php
final class RectangleTest extends TestCase
{
public function testASquareIsDetected(): void
{
$square = new Rectangle(5.0, 5.0);
$this->assertTrue($square->isSquare());
}
public function testEqualsVsSame(): void
{
$this->assertEquals(1, "1"); // passes, loose comparison, like ==
$this->assertSame(1, "1"); // fails, strict comparison, like ===
}
}
This distinction is not a PHPUnit quirk: it’s the exact same == versus === distinction from Chapter 3, wearing an assertion-shaped hat. assertEquals() allows type juggling: 1 and "1" are “equal enough.” assertSame() refuses it, checking type and value together, the same way === does. Default to assertSame() when you can: it catches a category of bug (a function accidentally returning a string where you expected an int) that assertEquals() will let straight through without complaint. Reach for assertEquals() only when the loose comparison is genuinely what you mean to test.
A failing assertion tells you exactly what went wrong:
$ vendor/bin/phpunit tests
1) RectangleTest::testEqualsVsSame
Failed asserting that 1 is identical to '1'.
That message is doing real work: it’s telling you the types didn’t match, not just that “something” was unequal. Read failure messages closely; PHPUnit is usually more specific than it looks at first glance.
Controlling How Tests Are Run
vendor/bin/phpunit tests runs everything, every time, which is fine for a handful of tests and increasingly annoying once you have hundreds. This section covers narrowing that down, plus the config file that makes the whole thing repeatable.
Filtering by name
--filter runs only tests whose method name matches a pattern:
$ vendor/bin/phpunit --filter testAreaOfARectangle tests
It matches against the method name as a regular expression, so --filter Area would catch testAreaOfARectangle along with anything else containing “Area”: useful while you’re heads-down on one feature and don’t want the whole suite’s noise on every run.
Grouping tests
For a coarser cut than one test at a time, tag tests with a group, using either the older docblock annotation or the modern attribute:
<?php
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
final class RectangleTest extends TestCase
{
#[Group('geometry')]
public function testAreaOfARectangle(): void
{
$rectangle = new Rectangle(8.0, 7.0);
$this->assertSame(56.0, $rectangle->area());
}
}
Then run just that group:
$ vendor/bin/phpunit --group geometry tests
A common real-world use: mark slow tests (ones that hit a database, or the filesystem, or the network) with #[Group('slow')], and exclude them from your everyday inner loop with --exclude-group slow, saving the full run for CI where a few extra seconds don’t cost you anything.
phpunit.xml
Typing tests and remembering your preferred flags on every single invocation gets old fast. A phpunit.xml file at your project root fixes that: PHPUnit reads it automatically, no flag needed:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
bootstrap tells PHPUnit which file to load before anything else: almost always Composer’s autoloader, so your test files can reference Rectangle without a manual require. testsuites defines what “the test suite” even means: here, everything under tests/. With this file in place, the invocation shrinks back down to the bare command:
$ vendor/bin/phpunit
You can still layer --filter or --group on top of it whenever you need a narrower run. Generate a starting version of this file with vendor/bin/phpunit --generate-configuration if you’d rather answer a few prompts than hand-write the XML: either way, commit the resulting file. It’s project configuration, not a personal preference, and everyone on the team (plus your CI pipeline) should be running the same suite the same way.
Test Organization
Where tests live matters less for correctness than it does for whether anyone (including future you) can find them. PHP’s convention here is simple enough to state in one sentence: a tests/ directory that mirrors the shape of src/, with one test class per class, named after the thing it tests plus Test.
src/
Rectangle.php
GrepOptions.php
tests/
RectangleTest.php
GrepOptionsTest.php
src/Rectangle.php gets tests/RectangleTest.php. src/Http/Client.php would get tests/Http/ClientTest.php, keeping the directory structure lined up on both sides. This isn’t enforced by PHPUnit (you could name things however you like and point phpunit.xml at whatever directory you chose), but it’s the convention nearly every PHP project follows, and deviating from it without a good reason just makes your codebase slightly harder for the next person to navigate. Composer’s PSR-4 autoloading, from Chapter 7, usually maps a Tests\ namespace onto this tests/ directory the same way it maps your application namespace onto src/.
Unit tests vs. integration tests
A unit test exercises one class or function, in isolation, with nothing outside it involved: no database, no filesystem, no network. RectangleTest is a unit test: it constructs a Rectangle directly and checks its own methods, nothing more. Unit tests are fast (thousands of them can run in a few seconds), which is exactly what lets you run the whole suite constantly without it slowing you down.
An integration test checks that several pieces work correctly together: your code talking to a real database, a real file on disk, a real HTTP call to another service. They catch a category of bug unit tests structurally cannot: the assumptions two pieces make about each other turning out to be wrong, even though each piece is individually correct. They’re also slower, often by orders of magnitude, and more prone to flaking for reasons that have nothing to do with your code: a slow disk, a network blip.
<?php
use PHPUnit\Framework\TestCase;
final class FindMatchingLinesIntegrationTest extends TestCase
{
public function testFindsLinesInARealFile(): void
{
$path = tempnam(sys_get_temp_dir(), 'lines');
file_put_contents($path, "apple\nbanana\ncherry\n");
$lines = findMatchingLines($path, 'banana');
$this->assertSame(['banana'], $lines);
unlink($path);
}
}
Nothing here is exotic (it’s still a PHPUnit TestCase, still full of assertions), but notice it touches the real filesystem, creating and cleaning up an actual temporary file, rather than faking one. That’s the distinguishing feature, not the syntax.
A practical split
Most projects keep both kinds of test in the same tests/ tree but separate them by directory or by PHPUnit group (tests/Unit/ and tests/Integration/, or the #[Group('integration')] attribute from the previous section), so you can run the fast unit suite constantly while you work, and save the slower integration suite for before a commit or for CI. Neither kind replaces the other: unit tests tell you a piece works on its own; integration tests tell you the pieces still work once they’re talking to each other, which is, after all, the only way your program actually runs.
Debugging PHP
Every program in this book so far has been small enough to read start to finish and spot the bug by eye. That stops being true quickly, and the guessing game and the web basics chapter you just finished are already big enough that “just read it carefully” isn’t always going to cut it. Debugging is the skill of finding out what a program is actually doing, as opposed to what you meant it to do, and it’s worth treating as a skill in its own right rather than something you pick up by accident.
This chapter covers two approaches, and you’ll want both. The first, print debugging, is the oldest trick in the book: put something in the middle of your code that shows you a value, rerun the program, read the output. It needs nothing but PHP itself, and var_dump() and print_r() are the tools for it. The second, step debugging, is more surgical: pause the program mid-execution, inspect every variable in scope exactly as it stood at that moment, and step forward one line at a time. That takes a tool, Xdebug, and a few minutes of setup, but it earns that setup back the first time a bug doesn’t announce itself with an obvious wrong value to print.
Neither replaces the error handling from Chapter 9: a well-placed exception tells you that something went wrong. Debugging is what you reach for to find out why, especially when nothing threw at all and the program just quietly produced the wrong answer. The CLI project in Chapter 14 is exactly the kind of multi-file program where that distinction starts to matter, and where both of these tools earn their keep.
Print Debugging with var_dump() and print_r()
You’ve already met var_dump(), briefly, back in Chapter 3: it’s the function that shows you a value’s type along with the value itself. That combination is exactly what makes it a debugging tool and not just an inspection one: the bug is very often that a value has the wrong type, not the wrong contents, and echo alone can’t tell you that. echo $count prints 5 whether $count is the integer 5 or the string "5". var_dump($count) prints int(5) or string(1) "5", and the difference between those two is frequently the entire bug.
var_dump() on structured data
var_dump() isn’t limited to a single scalar. Hand it an array or an object and it recurses, showing you the whole shape:
<?php
$user = [
'name' => 'Alice',
'age' => '32',
'active' => true,
'roles' => ['admin', 'editor'],
];
var_dump($user);
array(4) {
["name"]=>
string(5) "Alice"
["age"]=>
string(2) "32"
["active"]=>
bool(true)
["roles"]=>
array(2) {
[0]=>
string(5) "admin"
[1]=>
string(6) "editor"
}
}
Notice "age" came back as string(2) "32", not int(32). If this array came from a form submission (the kind of data Chapter 10 reads out of $_POST), that’s expected: everything in $_POST arrives as a string, and code further down that assumes $user['age'] is already an integer is a bug waiting to happen. That’s the kind of thing var_dump() catches in seconds that a quiet wrong answer three functions later would take much longer to trace back.
You can hand var_dump() more than one argument at once, which dumps each in turn: var_dump($name, $age, $roles) is shorter than three separate calls.
print_r(): easier to read, less precise
print_r() shows the same structural information without the types, in a format that’s noticeably easier to scan for a large nested array:
<?php
print_r($user);
Array
(
[name] => Alice
[age] => 32
[active] => 1
[roles] => Array
(
[0] => admin
[1] => editor
)
)
That’s a reasonable trade: reach for print_r() when you just want to see the shape of something quickly, and var_dump() the moment a value’s exact type is in question, which it usually is once you’re specifically hunting a bug rather than just looking something up. One more difference matters in practice: print_r() takes an optional second argument, and passing true makes it return the formatted string instead of printing it:
<?php
$snapshot = print_r($user, true);
error_log("user state: {$snapshot}");
var_export() is a third option worth knowing about, closer to print_r() than var_dump() in what it shows, but it formats its output as valid PHP source rather than a description meant for a human: var_export($user) prints something you could paste directly back into a script as an array literal. Handy for capturing a real value as a test fixture.
The limits of printing things
All three of these share the same weakness: you have to already suspect where the problem is before you know where to put the call, and every time you want to look somewhere new, you edit the file and rerun the program. For a script the size of anything so far in this book, that’s a fine way to work. Once a bug depends on the exact sequence of several function calls, or shows up only on the fifth iteration of a loop, or lives inside a library you’d rather not edit, printing things stops being surgical and starts being trial and error. The next section covers the tool for that: Xdebug, which lets you pause a running script and look around, instead of guessing where to point a flashlight in advance.
Step Debugging with Xdebug
Xdebug is a PHP extension, not a separate program: once it’s installed, it changes how PHP itself behaves, rather than being something you call from your code the way you call var_dump(). That’s a bit more setup than the previous section needed, but it buys you something print debugging can’t: the ability to pause a running script at an exact line and look at everything in scope, without having guessed in advance what to print.
Installing it
Xdebug isn’t bundled with PHP, so it needs installing separately, and the exact command depends on your platform:
$ pecl install xdebug
Most package managers offer it too (apt install php-xdebug on Debian and Ubuntu, brew install php followed by pecl install xdebug on macOS with Homebrew’s PHP). Whichever route you take, it needs to be enabled in php.ini afterward with a line resembling:
zend_extension=xdebug
Confirm it loaded:
$ php -v
PHP 8.3.0 (cli) (built: ...)
with Xdebug v3.3.0, Copyright (c) 2002-2024, by Derick Rethans
If Xdebug’s name shows up in that output, it’s active.
xdebug.mode: turning on what you need
Xdebug does several unrelated things, and a single setting, xdebug.mode, controls which of them are switched on, as a comma-separated list in php.ini:
xdebug.mode=develop,debug
develop is worth having on by default: it doesn’t require any tooling at all, and it quietly improves output you’re already producing. With it enabled, var_dump() prints with color and includes the file and line it was called from, and an uncaught exception’s output grows a full stack trace, arguments included, instead of PHP’s terser default. debug is the mode that enables the part this section is really about: pausing execution for an external tool to inspect.
Connecting an editor
Step debugging needs two ends talking to each other: PHP, running your script, and an editor or IDE, listening for it to say “I’ve paused, come look.” Both PhpStorm and VS Code (with the “PHP Debug” extension) support this out of the box, over a protocol called DBGp, on port 9003 by default. Set up in either is roughly the same shape:
- Start “listening for Xdebug connections” in the editor.
- Click in the gutter next to a line of code to set a breakpoint: a red dot marking “pause here.”
- Run the script (
php your_script.phpfrom the terminal, or reload a page served byphp -S, withxdebug.modeincludingdebug). - Execution stops the moment it reaches that line, before running it, and the editor shows every variable in scope at that exact point.
From there you step over a line (run it, stop again at the next one), step into a function call (follow execution inside it instead of running it as a block), or step out of the current function back to its caller, watching variables change as you go.
Trying it on the guestbook
The validation code from Chapter 10 is a good place to practice on, since it’s small enough to hold in your head but has a real branch worth watching:
if ($name === '') {
$errors[] = 'Name cannot be empty.';
} elseif (mb_strlen($name) > 60) {
$errors[] = 'Name is too long.';
}
Set a breakpoint on the if ($name === '') line, start the built-in server with xdebug.mode=debug set, start listening in your editor, and submit the guestbook form with the name field left blank. Execution pauses right there, and the variables pane shows $name as an empty string, $errors as an empty array, exactly as they stood at that instant, before a single line of the if block has run. Step over it, and watch $errors gain its first entry in real time. That’s the entire value of step debugging in one small example: no var_dump() call had to be written, moved, or removed to see it.
Profiling, briefly
xdebug.mode=profile turns on a third capability: instead of pausing execution, it records how long each function call took, writing the result to a “cachegrind” file (xdebug.output_dir controls where). Tools like QCachegrind or the profiler built into PhpStorm read that file and show you, visually, exactly where a slow request spent its time: which function, called how many times, accounting for what fraction of the total. It’s a different job from debugging a wrong answer, closer to what Chapter 15 discusses for loops versus generators, but it’s the same extension and worth knowing it’s there.
Choosing between the two tools
Reach for var_dump() and print_r() first, honestly: they need no setup, and for most of the bugs you’ll hit, especially early on, “print the value and look at it” finds the problem in seconds. Reach for Xdebug once printing stops narrowing things down, once a bug depends on a sequence of calls rather than a single value, or once you find yourself adding and removing var_dump() calls three or four times chasing the same problem. That’s the point where pausing the program and just looking around costs less time than guessing again.
A CLI Project: Building a Command Line Program
Time to build something with a bit more shape than the exercises so far. Over the next six sections you’ll write phpgrep, a small command-line tool that searches a text file for lines containing a word: a scaled-down version of the grep you’ve probably already used from a terminal, if you’ve spent any time near Unix.
This isn’t six unrelated examples. It’s one program, and each section picks up exactly where the last one left off, the same way Chapter 2 built its guessing game one capability at a time. You’ll start with the crudest possible version (read two arguments, print them) and end with something that reads its input properly, reports errors the way a real command-line tool should, is covered by tests you wrote before the code that makes them pass, and respects an environment variable to change its behavior. Along the way you’ll lean on nearly everything the book has covered so far: classes and constructor promotion from Chapter 5, exceptions from Chapter 9, and PHPUnit from Chapter 12.
There’s a reason a search tool makes a good teaching project, beyond tradition: it’s small enough to hold in your head completely, but it still has a file to read, arguments to parse, a place where things can legitimately go wrong (a missing file), and a genuine feature worth adding carefully (case-insensitive matching) rather than just for show. Every piece of it is something you’ll do again, in some form, in real work.
Type the code along as you go rather than copying it in one block at the end. The value of this chapter is in watching the program change shape across sections (clumsy first, then modular, then tested, then polished) not in the final file on its own. And keep the project around once you’re done: Chapter 15 comes back to phpgrep and gives it one more upgrade, once you’ve met a PHP feature it’s specifically well suited to.
Accepting Command Line Arguments
Create a new directory for the project and, inside it, a file called phpgrep.php. Every PHP script run from the command line has access to a superglobal array called $argv, holding everything typed after php on the command line:
<?php
declare(strict_types=1);
var_dump($argv);
$ php phpgrep.php apple fruits.txt
array(3) {
[0]=>
string(11) "phpgrep.php"
[1]=>
string(5) "apple"
[2]=>
string(9) "fruits.txt"
}
The first surprise, if you haven’t met $argv before: $argv[0] is not your first argument, it’s the name of the script itself. This trips up nearly everyone once. Your actual arguments start at index 1. Here, $argv[1] is the word we’re searching for, and $argv[2] is the file to search in: that’s the whole interface phpgrep needs to expose.
Reading two arguments, badly
The most direct way to grab them:
<?php
declare(strict_types=1);
$query = $argv[1];
$filename = $argv[2];
echo "Searching for \"{$query}\" in \"{$filename}\"\n";
Run it right, and it works fine. Run it with too few arguments:
$ php phpgrep.php apple
PHP raises a warning for the missing $argv[2], then quietly treats it as null, and the program limps on with garbage input instead of stopping to tell you what went wrong. That’s not acceptable for a tool anyone but you will ever run. Let’s guard it:
<?php
declare(strict_types=1);
if (count($argv) < 3) {
echo "Usage: php phpgrep.php <query> <filename>\n";
exit(1);
}
$query = $argv[1];
$filename = $argv[2];
echo "Searching for \"{$query}\" in \"{$filename}\"\n";
exit(1) stops the script immediately and sets the process’s exit code to 1: by Unix convention, 0 means “the program succeeded,” and anything nonzero means “something went wrong.” Every command-line tool you’ve ever chained together with && or checked with $? in a shell relies on this convention; phpgrep should honor it too, from the very first version.
Giving the arguments a home: GrepOptions
Two loose variables, $query and $filename, are fine for now, but this project is going to grow, and passing a pair of separate strings around every function we write gets unwieldy fast. Let’s bundle them into a small, dedicated value object instead: a class whose entire job is holding “the options this run of the program was given,” nothing more:
<?php
declare(strict_types=1);
final class GrepOptions
{
public function __construct(
public readonly string $query,
public readonly string $filename,
) {
}
public static function fromArgv(array $argv): self
{
return new self(
query: $argv[1],
filename: $argv[2],
);
}
}
if (count($argv) < 3) {
echo "Usage: php phpgrep.php <query> <filename>\n";
exit(1);
}
$options = GrepOptions::fromArgv($argv);
echo "Searching for \"{$options->query}\" in \"{$options->filename}\"\n";
Two readonly properties, set once through constructor promotion, exactly as you saw back in Chapter 5: a GrepOptions can’t be modified after it’s built, which is precisely right for something meant to represent “what the user asked for” for the lifetime of one run. fromArgv() is a static factory method: it takes the raw $argv array and hands back a fully-formed GrepOptions, keeping the “how do we parse arguments” question in exactly one place. Every future section in this chapter builds on this same class: you’ll see it grow a third property soon enough, but its shape and its job stay the same.
Run it once more to confirm nothing changed from the reader’s point of view:
$ php phpgrep.php apple fruits.txt
Searching for "apple" in "fruits.txt"
Same behavior, better bones. That’s the whole point of introducing the class this early: it costs almost nothing now, and it’s exactly the seam the rest of this chapter needs.
Reading a File
phpgrep can parse its arguments now, but it doesn’t actually search anything yet. Time to fix that. Create a small file to search in, right next to phpgrep.php:
$ cat fruits.txt
Apple pie recipe
apple sauce for the win
Banana bread is better
cherry clafoutis
Reading the whole file into lines
PHP’s file() function reads a file straight into an array, one element per line: exactly the shape we want:
<?php
$lines = file($options->filename, FILE_IGNORE_NEW_LINES);
The FILE_IGNORE_NEW_LINES flag strips the trailing \n from each line as it reads, which saves you a trim() call on every single one afterward. You could reach for file_get_contents() followed by explode("\n", ...) instead (same result, two steps instead of one), and you’ll see that combination plenty in real code, particularly when you need the raw file contents for something else too. For a line-by-line tool like this one, file() is the more direct match.
Searching each line
With the lines in hand, str_contains() (introduced in PHP 8, and a welcome relief after years of everyone hand-rolling strpos($haystack, $needle) !== false) does the actual matching:
<?php
declare(strict_types=1);
final class GrepOptions
{
public function __construct(
public readonly string $query,
public readonly string $filename,
) {
}
public static function fromArgv(array $argv): self
{
return new self(
query: $argv[1],
filename: $argv[2],
);
}
}
if (count($argv) < 3) {
echo "Usage: php phpgrep.php <query> <filename>\n";
exit(1);
}
$options = GrepOptions::fromArgv($argv);
$lines = file($options->filename, FILE_IGNORE_NEW_LINES);
foreach ($lines as $line) {
if (str_contains($line, $options->query)) {
echo $line . "\n";
}
}
$ php phpgrep.php apple fruits.txt
apple sauce for the win
Only the lowercase apple line matched: str_contains() is case-sensitive, and "Apple pie recipe" doesn’t contain the literal substring "apple". Keep that fixture and that behavior in mind; it becomes the exact test case for case-insensitive matching a couple of sections from now.
The file that isn’t there
Try pointing phpgrep at a file that doesn’t exist:
$ php phpgrep.php apple missing.txt
Warning: file(missing.txt): Failed to open stream: No such file or directory in phpgrep.php on line 20
A warning, printed to the terminal, and then… nothing. file() returns false when it can’t open the target, and our foreach silently iterates over false as if it were an empty array, producing no matches and no explanation. That’s a genuinely bad failure mode: the tool looks like it ran successfully and simply found nothing, when what actually happened is it never read anything at all.
Let’s patch that with the bluntest tool available: check first, and bail out if the file isn’t there:
<?php
if (!file_exists($options->filename)) {
echo "Error: file \"{$options->filename}\" not found.\n";
exit(1);
}
$lines = file($options->filename, FILE_IGNORE_NEW_LINES);
$ php phpgrep.php apple missing.txt
Error: file "missing.txt" not found.
Better: at least it’s honest now. But look at what this check actually buys you, and what it doesn’t. file_exists() only answers “is there something at this path.” It says nothing about whether you can read it: a file that exists but has permissions locked down still passes this check and then fails at file() exactly as before, warning and all. And every place in this program that might eventually open a file would need this same manual check copy-pasted in front of it, with every copy a chance to forget one. That’s a clumsy, incomplete guard standing in for something PHP has a proper mechanism for. Time to reach for it.
Refactoring to Improve Modularity and Error Handling
Everything phpgrep does still lives in one script, top to bottom: parse arguments, check the file, read it, loop over it, print matches. That’s fine for thirty lines. It stops being fine the moment you want to test any single piece of it without running the whole program, which is exactly where this project is headed in a couple of sections. Let’s split it up properly, and replace that clumsy file_exists() check with something PHP actually designed for this: an exception.
A named exception
Chapter 9 made the case for throwing a specific, well-named exception instead of returning a sentinel value or printing an error and hoping the caller checks. PHP’s built-in RuntimeException is the right base class for “something went wrong at runtime that the caller should have a chance to handle”: extend it with a name that says exactly what happened:
<?php
// src/FileNotFoundException.php
declare(strict_types=1);
final class FileNotFoundException extends RuntimeException
{
}
That’s the entire class. It adds no new behavior; it doesn’t need to. Its whole value is its name: catching FileNotFoundException specifically, rather than a generic RuntimeException or, worse, Exception, tells the reader of the catch block precisely what failure they’re handling, without them needing to go read the code that threw it.
Extracting search()
Now pull the reading-and-matching logic out of the top-level script and into a function with a real name and a real contract: it takes a GrepOptions, returns an array of matching lines, and throws if the file can’t be read:
<?php
// src/search.php
declare(strict_types=1);
require_once __DIR__ . '/FileNotFoundException.php';
function search(GrepOptions $options): array
{
if (!is_readable($options->filename)) {
throw new FileNotFoundException("Cannot read file: {$options->filename}");
}
$lines = file($options->filename, FILE_IGNORE_NEW_LINES);
$matches = [];
foreach ($lines as $line) {
if (str_contains($line, $options->query)) {
$matches[] = $line;
}
}
return $matches;
}
is_readable() is a genuine improvement over file_exists(): it checks the file exists and that the current process has permission to read it, which is the actual precondition file() needs. Fail either check, and search() throws immediately, with a message that says exactly which file was the problem. No warning printed to a stream nobody’s watching, no silent empty result, just a clear, catchable failure.
GrepOptions moves into its own file too, unchanged from the last section:
<?php
// src/GrepOptions.php
declare(strict_types=1);
final class GrepOptions
{
public function __construct(
public readonly string $query,
public readonly string $filename,
) {
}
public static function fromArgv(array $argv): self
{
return new self(
query: $argv[1],
filename: $argv[2],
);
}
}
The entry script, now just wiring
With GrepOptions and search() living in src/, phpgrep.php shrinks down to what it should have been all along: the part that talks to the outside world, and nothing else.
<?php
// phpgrep.php
declare(strict_types=1);
require __DIR__ . '/src/GrepOptions.php';
require __DIR__ . '/src/FileNotFoundException.php';
require __DIR__ . '/src/search.php';
function main(array $argv): int
{
if (count($argv) < 3) {
echo "Usage: php phpgrep.php <query> <filename>\n";
return 1;
}
$options = GrepOptions::fromArgv($argv);
try {
$matches = search($options);
} catch (FileNotFoundException $e) {
echo "Error: {$e->getMessage()}\n";
return 1;
}
foreach ($matches as $line) {
echo $line . "\n";
}
return 0;
}
exit(main($argv));
Notice main() returns an exit code rather than calling exit() itself in the middle of the function: exit() only happens once, at the very last line of the file, wrapping whatever main() decided. That’s a small discipline with a real payoff: a function that returns a value instead of unilaterally killing the process is a function you can call from anywhere, including, and this is the point, from a test, where you’d very much like main()’s mistakes to come back as a return value you can assert on, not as your test runner’s process disappearing mid-suite.
$ php phpgrep.php apple fruits.txt
apple sauce for the win
$ php phpgrep.php apple missing.txt
Error: Cannot read file: missing.txt
Same behavior from the outside as before: that’s deliberate. Nothing about what phpgrep does changed in this section, only how it’s built. That distinction is worth sitting with: a refactor that changes behavior isn’t a refactor, it’s a rewrite wearing a refactor’s name. What did change is that search() and GrepOptions now live in files that never call exit(), never call echo, and never touch $argv directly, which means, as of this section, they’re finally things a test can call directly and check, without launching the whole program to do it. That’s exactly what the next section does.
Adding Functionality with Test-Driven Development
search() and GrepOptions now live outside the entry script, in files that don’t parse $argv, don’t echo, and don’t exit. That was the whole point of the last section’s refactor, and it means, for the first time in this project, we can write a PHPUnit test against them directly, the way Chapter 12 taught. Let’s use that to add a real feature: case-insensitive search.
Set up the project the same way you did there:
$ composer require --dev phpunit/phpunit
Red: write the test you wish already passed
Go back to fruits.txt from a couple of sections ago:
Apple pie recipe
apple sauce for the win
Banana bread is better
cherry clafoutis
Searching for apple right now only finds the lowercase line: str_contains() doesn’t fold case. Let’s write down, as a test, the behavior we actually want: a GrepOptions with case-insensitivity turned on should match both "Apple pie recipe" and "apple sauce for the win".
<?php
// tests/SearchTest.php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../src/GrepOptions.php';
require_once __DIR__ . '/../src/FileNotFoundException.php';
require_once __DIR__ . '/../src/search.php';
final class SearchTest extends TestCase
{
private string $fixture;
protected function setUp(): void
{
$this->fixture = tempnam(sys_get_temp_dir(), 'phpgrep');
file_put_contents(
$this->fixture,
"Apple pie recipe\napple sauce for the win\nBanana bread is better\ncherry clafoutis\n"
);
}
protected function tearDown(): void
{
unlink($this->fixture);
}
public function testSearchCanIgnoreCase(): void
{
$options = new GrepOptions(
query: 'apple',
filename: $this->fixture,
ignoreCase: true,
);
$this->assertSame(
['Apple pie recipe', 'apple sauce for the win'],
search($options)
);
}
}
setUp() and tearDown() are PHPUnit hooks that run before and after every test method in the class: perfect for building a fresh temporary fixture file per test and cleaning it up afterward, so tests never depend on leftover state from a previous run.
Run it:
$ vendor/bin/phpunit tests
PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
E 1 / 1 (100%)
Time: 00:00.014, Memory: 6.00 MB
1) SearchTest::testSearchCanIgnoreCase
Error: Unknown named parameter $ignoreCase
That’s red. Good, for entirely the right reason. GrepOptions doesn’t have an ignoreCase property at all yet, so PHP can’t even construct the object the test asks for. This is the whole rhythm of test-driven development in one step: write the test for the behavior you want before the code that provides it exists, watch it fail, and let that failure tell you exactly what to build next.
Green: make it pass
First, give GrepOptions the property the test is asking for:
<?php
// src/GrepOptions.php
declare(strict_types=1);
final class GrepOptions
{
public function __construct(
public readonly string $query,
public readonly string $filename,
public readonly bool $ignoreCase,
) {
}
public static function fromArgv(array $argv): self
{
return new self(
query: $argv[1],
filename: $argv[2],
ignoreCase: false,
);
}
}
fromArgv() passes a hardcoded false for now: wiring it up to something the user can actually control is next section’s job. Then teach search() to honor the flag:
<?php
// src/search.php
declare(strict_types=1);
require_once __DIR__ . '/FileNotFoundException.php';
function search(GrepOptions $options): array
{
if (!is_readable($options->filename)) {
throw new FileNotFoundException("Cannot read file: {$options->filename}");
}
$lines = file($options->filename, FILE_IGNORE_NEW_LINES);
$query = $options->ignoreCase ? strtolower($options->query) : $options->query;
$matches = [];
foreach ($lines as $line) {
$haystack = $options->ignoreCase ? strtolower($line) : $line;
if (str_contains($haystack, $query)) {
$matches[] = $line;
}
}
return $matches;
}
Both $line and $query get lowercased for the comparison when ignoreCase is on, but notice it’s the original, unmodified $line that gets pushed into $matches. We want case-insensitive matching, not case-mangled output.
$ vendor/bin/phpunit tests
PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.013, Memory: 6.00 MB
OK (1 test, 1 assertion)
Green. That’s the rhythm: red, then green, then (traditionally) refactor, though there’s not much worth reshaping here yet. Worth adding one more test while you’re in the file, just to pin down the behavior you’re not changing:
<?php
public function testSearchIsCaseSensitiveByDefault(): void
{
$options = new GrepOptions(
query: 'apple',
filename: $this->fixture,
ignoreCase: false,
);
$this->assertSame(['apple sauce for the win'], search($options));
}
That one passes immediately: it’s not testing new behavior, it’s guarding old behavior against a future regression. Both are worth having: the first proves the feature works, the second proves adding it didn’t quietly break what was already there.
Working with Environment Variables
GrepOptions can carry an ignoreCase flag, and search() honors it, but fromArgv() still hardcodes it to false. There’s no way for anyone running phpgrep from a terminal to actually turn it on. Let’s fix that using an environment variable rather than a third command-line argument.
Why an environment variable and not just $argv[3]? Because case-insensitivity here is closer to a standing preference than a per-search decision: something you might want on for every search you run in a given shell session, without retyping a flag every time. Environment variables are exactly the tool for that: set once, inherited by every command you run afterward, until you close the terminal or unset it.
Reading it with getenv()
<?php
// src/GrepOptions.php
declare(strict_types=1);
final class GrepOptions
{
public function __construct(
public readonly string $query,
public readonly string $filename,
public readonly bool $ignoreCase,
) {
}
public static function fromArgv(array $argv): self
{
return new self(
query: $argv[1],
filename: $argv[2],
ignoreCase: getenv('PHPGREP_IGNORE_CASE') !== false,
);
}
}
getenv('PHPGREP_IGNORE_CASE') returns the variable’s value as a string if it’s set, or the boolean false if it isn’t set at all: that’s why the check is !== false rather than something that tries to interpret the value as a boolean itself. It means PHPGREP_IGNORE_CASE=1 turns the flag on, but so would PHPGREP_IGNORE_CASE= with nothing after the =: an empty string is still a value, and simply setting the variable at all is treated as “on.” If that looseness bothers you, you’re right to notice it, and tightening it (say, requiring the value to be exactly "1") is a reasonable improvement to make on your own once the chapter’s done.
Trying it
$ php phpgrep.php APPLE fruits.txt
No output at all: the query APPLE doesn’t appear, case-sensitively, in either the Apple or apple line, so nothing matches and phpgrep prints nothing. Now flip the flag on:
$ PHPGREP_IGNORE_CASE=1 php phpgrep.php APPLE fruits.txt
Apple pie recipe
apple sauce for the win
Setting PHPGREP_IGNORE_CASE=1 immediately before the command, on the same line, sets it for that single invocation only: a common and useful shell idiom when you don’t want a setting to outlive the command it’s attached to. Export it instead, and it sticks around for the rest of the session:
$ export PHPGREP_IGNORE_CASE=1
$ php phpgrep.php APPLE fruits.txt
Apple pie recipe
apple sauce for the win
getenv() versus $_ENV
PHP also exposes environment variables through the $_ENV superglobal, and it’s worth knowing why this chapter didn’t reach for it. $_ENV is only populated according to the variables_order setting in php.ini: on plenty of default PHP installations, particularly ones tuned for web serving rather than CLI use, E is missing from that setting entirely, and $_ENV ends up empty regardless of what’s actually in the process environment. getenv() has no such dependency: it asks the operating system directly, every time, and works consistently across CLI scripts, web requests, and every hosting configuration you’re likely to encounter. For a command-line tool meant to run reliably wherever it’s installed, that consistency is worth the slightly less fashionable syntax.
Writing to Standard Error
phpgrep has been printing everything the same way since the very first section: matches, usage messages, and error text all go through echo, all landing on the same output stream. That’s been a quiet, low-grade problem the whole time, and this section is where it finally bites.
Every process has two separate output streams, not one: standard output (STDOUT), where a program’s actual results belong, and standard error (STDERR), where diagnostics, warnings, and error messages belong. echo always writes to the first one. phpgrep’s error messages have been going there too, right alongside legitimate matches, which is fine, as long as you only ever look at the terminal directly. It stops being fine the moment someone pipes phpgrep’s output somewhere else, which is the entire reason command-line tools exist.
Watch it go wrong
$ php phpgrep.php apple missing.txt > results.txt
$ cat results.txt
Error: Cannot read file: missing.txt
That error message just landed inside results.txt. Whatever consumed that file next (another script, a report, a colleague trusting it contained only search results) now has to contend with a stray error line mixed into what should have been clean data, with nothing marking it as different from a real result. This is exactly the failure mode STDOUT/STDERR separation exists to prevent, and it’s why every well-behaved command-line tool keeps the two apart.
Fixing it with fwrite(STDERR, ...)
PHP exposes standard error as the constant STDERR, and fwrite() writes to it directly, bypassing echo entirely:
<?php
// phpgrep.php
declare(strict_types=1);
require __DIR__ . '/src/GrepOptions.php';
require __DIR__ . '/src/FileNotFoundException.php';
require __DIR__ . '/src/search.php';
function main(array $argv): int
{
if (count($argv) < 3) {
fwrite(STDERR, "Usage: php phpgrep.php <query> <filename>\n");
return 1;
}
$options = GrepOptions::fromArgv($argv);
try {
$matches = search($options);
} catch (FileNotFoundException $e) {
fwrite(STDERR, "Error: {$e->getMessage()}\n");
return 1;
}
foreach ($matches as $line) {
echo $line . "\n";
}
return 0;
}
exit(main($argv));
Only two lines changed: echo became fwrite(STDERR, ...) in both error paths, but the behavior at the boundary is completely different now:
$ php phpgrep.php apple missing.txt > results.txt
Error: Cannot read file: missing.txt
$ cat results.txt
$
The error message still shows up on your terminal immediately: STDERR is not hidden, it’s just a different stream, one that redirecting STDOUT with > doesn’t touch. And results.txt is now empty, exactly as it should be: no matches were found because the search never ran, and no error text is masquerading as a result. Try it again against a file that actually has matches, and the split holds up the same way: real results go to results.txt, any error text stays on your terminal, and the two never mix regardless of what you redirect.
Exit codes, one more time
main() still returns an int rather than calling exit() from inside itself, and the single exit(main($argv)) at the bottom of the file is still the only place the process actually terminates. That discipline from a couple of sections ago is doing double duty now. It’s what let SearchTest call search() directly without launching a process, and it’s the same reason main()’s return value cleanly becomes the process’s real exit code here: 1 on either failure path, 0 when it reaches the end having printed whatever it found, including printing nothing at all, which is a legitimate, successful outcome for a search tool, not a failure. A shell script or CI pipeline chaining phpgrep together with other commands can rely on that exit code exactly the way it relies on every other well-behaved Unix tool, without ever needing to parse phpgrep’s output to figure out whether it worked.
That’s phpgrep, for now: it accepts arguments properly, reads a file and searches it, fails loudly and specifically when it can’t, is backed by tests that exercise its actual logic, respects an environment variable, and keeps its results and its errors on separate streams the way a command-line tool should. It’s a small program, but there’s very little about it left to apologize for.
One more improvement is coming. Chapter 15 introduces generators, and once it does, it comes back to this exact project for one last pass.
Functional Features: Closures and Generators
Back in Chapter 3, you saw that functions in PHP are values: you can hold one in a variable, and you already met the anonymous kind, closures, in passing. This chapter goes back for that material properly, because it’s not a curiosity. Passing a small piece of behavior into another function is one of the most common things you’ll do in real PHP code, and PHP gives you two ways to write that behavior inline: full closures, which can capture variables from the surrounding scope explicitly, and the terser arrow functions, which capture automatically.
The second half of the chapter is about a different problem entirely: what happens when the data you’re iterating over is too large, too slow to produce, or simply not something you want to build all at once before you start working with it. That’s what generators are for. A generator function looks almost like an ordinary function, but instead of building up a result and returning it in one go, it hands values back to its caller one at a time, pausing itself in between. You’ll see exactly how that changes what a function can do, and why it matters.
Once both pieces are on the table, we’ll put them to work. The phpgrep command-line tool you built across Chapter 14 currently reads a whole file and builds an array of every matching line before it prints anything. That’s fine for a small file and a genuine problem for a large one. We’ll revisit search() and rewrite it as a generator, and you’ll see the difference it makes concretely, not just in theory.
The chapter closes with an honest look at performance: not benchmarks for their own sake, but a practical comparison of plain loops, generators, and PHP’s built-in array functions like array_map() and array_filter(), so you know which one to reach for and why, rather than picking whichever one you saw most recently in someone else’s code.
Closures and Arrow Functions
You met the shape of a closure at the very end of Chapter 3: an anonymous function, assigned to a variable, callable just like any other. What we skipped over is the interesting part: how a closure gets at variables from the code around it, and the two very different ways PHP lets you control that.
Capturing by value with use
A closure doesn’t automatically see the variables in its enclosing scope. You have to tell it which ones to bring along, with use:
<?php
declare(strict_types=1);
function makeMultiplier(int $factor): callable
{
return function (int $n) use ($factor): int {
return $n * $factor;
};
}
$double = makeMultiplier(2);
$triple = makeMultiplier(3);
echo $double(21) . "\n"; // 42
echo $triple(21) . "\n"; // 63
use ($factor) copies the value of $factor into the closure at the moment the closure is created: the same by-value semantics you already know from Chapter 4. $double and $triple each got their own frozen copy of $factor (2 and 3 respectively) when makeMultiplier() created them, and nothing that happens afterward to any variable named $factor anywhere else can touch either one. That’s exactly why calling makeMultiplier() twice hands back two closures that behave differently forever, even though they came from the same code.
Capturing by reference with use (&$var)
Sometimes you want the opposite: a closure that shares live storage with a variable in the enclosing scope, so a change on one side is visible on the other. That’s use (&$var), the same & you saw attached to function parameters:
<?php
declare(strict_types=1);
function makeCounter(): callable
{
$count = 0;
return function () use (&$count): int {
$count++;
return $count;
};
}
$counter = makeCounter();
echo $counter() . "\n"; // 1
echo $counter() . "\n"; // 2
echo $counter() . "\n"; // 3
$count lives inside makeCounter(), and by all rights should disappear the moment that function returns. It doesn’t, because the closure holds a reference to it: the same aliasing mechanics from Chapter 4, just applied to a variable that only the closure can see. Each call to $counter() mutates the shared storage and hands back the new value. Without the &, every call would see its own fresh copy of $count starting at 0, and this counter would be stuck printing 1 forever.
Arrow functions: capture without asking
Writing use for every variable a small closure needs gets tedious fast, especially for the one-liners you pass into things like array_map(). Arrow functions solve exactly that:
<?php
$factor = 3;
$triple = fn(int $n): int => $n * $factor;
echo $triple(14) . "\n"; // 42
No use clause anywhere, and $factor is still visible inside. That’s the whole point of fn: it automatically captures every variable it references from the enclosing scope, implicitly, by value, as if PHP had silently written use ($factor) for you. This is the main practical reason arrow functions exist. A regular closure makes you declare its captures; an arrow function just uses whatever’s in scope, at the cost of two real restrictions: the body is a single expression (whatever follows => is automatically the return value: no braces, no return, no statements before it), and the capture is always by value. If you need reference capturing, you need a full closure with use (&$var); there’s no arrow-function equivalent.
Where this actually gets used
In practice, you’ll write far more arrow functions than full closures, because most of the behavior you pass around is short. array_map(), array_filter(), and usort() are the classic homes for this:
<?php
declare(strict_types=1);
$prices = [10.00, 25.50, 3.99, 100.00];
$withTax = array_map(fn(float $p): float => round($p * 1.2, 2), $prices);
$expensive = array_filter($prices, fn(float $p): bool => $p > 20.00);
usort($prices, fn(float $a, float $b): int => $a <=> $b);
array_map() applies the closure to every element and returns a new array of the results. array_filter() keeps only the elements for which the closure returns something truthy. usort() sorts an array in place using the closure to compare two elements at a time: <=>, the spaceship operator, is the standard way to write that comparison, returning a negative, zero, or positive number depending on order. None of these three functions needed anything more than a short arrow function, which is exactly the case they were designed for. Reach for a full closure with use when you need to capture by reference, or when the logic genuinely needs more than one expression; otherwise, the arrow function is almost always the better default.
Processing a Series of Items with Generators
Every function you’ve written so far that hands back a series of values has done it the same way: build an array, fill it up, return it. That’s fine right up until the series is big enough that building the whole thing before anyone looks at a single item stops being fine. Generators are PHP’s answer: a function that produces values one at a time, on demand, instead of all at once.
The array way, and its limit
Here’s an ordinary function that returns the first $max square numbers:
<?php
declare(strict_types=1);
function squaresUpTo(int $max): array
{
$result = [];
for ($i = 1; $i <= $max; $i++) {
$result[] = $i * $i;
}
return $result;
}
foreach (squaresUpTo(5) as $square) {
echo $square . "\n";
}
For five squares, nobody cares that squaresUpTo() builds the entire array before the foreach sees a single value. For five million, that’s five million integers sitting in memory before anything gets printed, and if all you actually needed was to look at the first three, you paid to build all five million anyway.
The same function, rewritten with yield
Change return into a series of yield statements, and change the return type to Generator:
<?php
declare(strict_types=1);
function squaresUpTo(int $max): Generator
{
for ($i = 1; $i <= $max; $i++) {
yield $i * $i;
}
}
foreach (squaresUpTo(5) as $square) {
echo $square . "\n";
}
The call site didn’t change at all: foreach doesn’t know or care whether it’s iterating an array or a generator. What changed is when the work happens. A function whose body contains yield doesn’t run that body when you call it. Calling squaresUpTo(5) returns a Generator object immediately, with nothing inside it computed yet. The loop only runs, one $i at a time, as foreach asks for the next value, and at any given moment, exactly one square exists, not all of them.
Watching the laziness happen
It’s worth seeing this directly, because “runs lazily” is easy to accept as a fact and much more convincing as something you watch happen:
<?php
function countUp(): Generator
{
echo "starting\n";
for ($i = 1; $i <= 3; $i++) {
echo "about to yield {$i}\n";
yield $i;
echo "resumed after {$i}\n";
}
}
$gen = countUp();
echo "generator created, nothing has run yet\n";
foreach ($gen as $value) {
echo "got {$value}\n";
}
$ php lazy.php
generator created, nothing has run yet
starting
about to yield 1
got 1
resumed after 1
about to yield 2
got 2
resumed after 2
about to yield 3
got 3
resumed after 3
Look at the order. Calling countUp() produces nothing, not even the "starting" line, because the body hasn’t run yet. Only when foreach starts pulling values does execution begin, and it stops the instant it hits yield, handing that value to the loop. "resumed after 1" doesn’t print until foreach comes back for the next value, at which point countUp() picks up exactly where it left off, mid-loop, with all its local state ($i included) intact. That pause-and-resume is the whole mechanism. A generator function is really a function that can be suspended and continued, and yield is where the suspending happens.
Associative generators
yield can produce key-value pairs too, using the same key => value syntax you’d use to build an associative array:
<?php
function statusCodes(): Generator
{
yield 200 => 'OK';
yield 404 => 'Not Found';
yield 500 => 'Internal Server Error';
}
foreach (statusCodes() as $code => $message) {
echo "{$code}: {$message}\n";
}
Everything else works the same way: the pairs are still produced lazily, one at a time, as foreach asks for them. This is a small feature, but a genuinely convenient one whenever the natural shape of what you’re generating already has an obvious key, the way an associative array often would.
Generators aren’t a replacement for arrays; plenty of code genuinely needs a real array it can index into, count, or pass to array_map(). What they’re for is exactly the case above: a series of values, produced by some logic, where nobody actually needs them all in memory at the same time. We’ll put that to real use in the next section, on a file that’s a good deal bigger than five squares.
Improving Our CLI Project
Chapter 14 left phpgrep in working order, with search() doing the real work:
<?php
declare(strict_types=1);
function search(GrepOptions $options): array
{
$contents = file_get_contents($options->filename);
if ($contents === false) {
throw new RuntimeException("Could not read file: {$options->filename}");
}
$matches = [];
foreach (explode("\n", $contents) as $line) {
$haystack = $options->ignoreCase ? strtolower($line) : $line;
$needle = $options->ignoreCase ? strtolower($options->query) : $options->query;
if (str_contains($haystack, $needle)) {
$matches[] = $line;
}
}
return $matches;
}
It works, and for the log files you tested it against, it’s fast enough that you never noticed anything wrong. Try it against a two-gigabyte log file, though, and you’ll notice two things at once: file_get_contents() reads the entire file into a single string before search() does anything else, and $matches keeps growing for as long as the loop runs. If that file has half a million matching lines, search() doesn’t hand back a single one of them until it has built an array holding all half million, and every line of the file, plus every matched line, is sitting in memory at the same time along the way.
Rewriting search() as a generator
Now that you know yield, the fix is direct: stop building $matches, and yield each match as you find it. But there’s a second change worth making at the same time: swap file_get_contents() (which reads the whole file up front) for fopen() and fgets(), which read it one line at a time. Otherwise you’d still be loading the entire file into memory before the generator even started producing anything, which defeats half the point:
<?php
declare(strict_types=1);
function searchLines(GrepOptions $options): Generator
{
$handle = fopen($options->filename, 'r');
if ($handle === false) {
throw new RuntimeException("Could not read file: {$options->filename}");
}
$needle = $options->ignoreCase ? strtolower($options->query) : $options->query;
while (($line = fgets($handle)) !== false) {
$haystack = $options->ignoreCase ? strtolower($line) : $line;
if (str_contains($haystack, $needle)) {
yield $line;
}
}
fclose($handle);
}
Two things changed shape, not just syntax. The file itself is now read a line at a time via fgets(), instead of all at once via file_get_contents(). And instead of appending to an array and returning it once the whole file has been scanned, searchLines() yields each match the moment it’s found, then goes right back to reading. Notice the exception check moved too: fopen() failing is now the thing that throws, since there’s no file_get_contents() call left to fail. The RuntimeException (the same class you saw introduced back in Chapter 9) still gets thrown before any yield happens, so a caller who never starts iterating never even attempts to open a file that doesn’t exist… except that’s not quite true, and it’s worth being honest about why: because searchLines()’s body contains yield, calling it doesn’t run any of this code yet, fopen() included. The exception won’t actually fire until the caller starts iterating. We’ll deal with that directly in the main script.
Updating phpgrep.php
The main script’s job barely changes: it still loops over whatever search gives it and prints each line, but the try/catch now has to wrap the loop itself, not just the call:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$options = GrepOptions::fromArgv($argv);
try {
foreach (searchLines($options) as $line) {
echo $line;
}
} catch (RuntimeException $e) {
fwrite(STDERR, "Error: {$e->getMessage()}\n");
exit(1);
}
That last point matters in practice, not just in theory: calling searchLines($options) on its own line, outside the try, would silently swallow the “file not found” case, because nothing would have actually tried to open the file yet. Wrapping the foreach instead of the call makes sure the exception, deferred as it is, still gets caught where you expect it.
Why this is worth doing
Point phpgrep at that same two-gigabyte log file again. With the array-returning search(), you wait, however long it takes to scan the entire file, and then, all at once, half a million lines print in a burst, after the program has held every one of them in memory simultaneously. With searchLines(), the very first match appears on screen almost immediately, before the rest of the file has even been read, because foreach only needed the first yielded value to start printing. And the program’s memory footprint stays flat throughout the whole run, regardless of file size or match count, because at any given moment it’s holding exactly one line: never “all matches so far,” never the whole file. That’s the entire trade generators offer: earlier results, and a memory ceiling that doesn’t move no matter how big the input gets.
Performance: Loops vs. Generators vs. Array Functions
You now have three ways to process a series of values in PHP: a plain for or foreach loop, a generator, or one of the built-in array functions like array_map() and array_filter(). They can often solve the same problem. They don’t cost the same, and “cost” here means two different things (time and memory) that don’t always move together. Let’s actually measure it instead of guessing.
A small benchmark
Here’s the same task (square two million integers and sum the results) done three ways:
<?php
declare(strict_types=1);
const N = 2_000_000;
// 1. plain loop
$sum = 0;
for ($i = 1; $i <= N; $i++) {
$sum += $i * $i;
}
// 2. array functions
$numbers = range(1, N);
$squares = array_map(fn(int $n): int => $n * $n, $numbers);
$sum = array_sum($squares);
// 3. generator
function squares(int $max): Generator {
for ($i = 1; $i <= $max; $i++) {
yield $i * $i;
}
}
$sum = 0;
foreach (squares(N) as $square) {
$sum += $square;
}
Run each version in its own process (so one doesn’t inflate another’s peak-memory reading) and measure with hrtime() and memory_get_peak_usage(). On the machine this book was written on:
loop ~100 ms peak memory: 2 MB
array functions ~140 ms peak memory: 66 MB
generator ~170 ms peak memory: 2 MB
Take the exact numbers with a grain of salt: they’ll shift with your PHP version, your hardware, and what else is running. The shape of the result is the part worth trusting: the plain loop is fastest and leanest, full stop. The array-function version is the slowest and by far the hungriest, because range() builds a two-million-element array, then array_map() builds a second two-million-element array to hold the squares, and both exist in memory at once before array_sum() even starts. The generator lands in between on time (there’s real overhead to suspending and resuming a function two million times) but matches the loop’s flat, minimal memory use, because it never materializes anything bigger than one value.
Reading that honestly
None of this means “always use loops.” It means the three tools are optimized for different things, and picking one is about which of those things you actually need:
A for/foreach loop is the fastest and most explicit option, and often the clearest to read besides: there’s no framework to understand, just a variable changing on every pass. Reach for it when performance matters, when the logic is more than a one-line transformation, or honestly, whenever you’re not sure; it’s rarely the wrong default.
A generator trades a bit of raw speed for a flat memory ceiling on data that’s large, streaming, or expensive to produce all at once: a huge file, an API you’re paging through, an infinite sequence. You saw this pay off directly in the previous section: phpgrep printing its first match before finishing the file, instead of waiting to build one giant array of results. If the whole series would comfortably fit in memory anyway, a generator’s suspend-and-resume overhead is buying you nothing.
array_map() and array_filter() are often the most readable option for small-to-medium in-memory transformations: a one-line array_map(fn($x) => ..., $items) reads better than the equivalent five-line loop, and that’s a genuine win worth having. What they are not is a memory optimization: each one builds a brand-new array to hold its result, on top of whatever you passed in. For a hundred items, that’s irrelevant. For millions, it’s the difference you just watched in the benchmark above.
A decision rule, not a table
If the data is small enough that you’d never think twice about holding it all in memory, reach for whichever reads best at the call site: usually an array function for a simple transformation, a loop for anything with real logic in it. If the data is large, unbounded, or expensive to produce (a big file, a database cursor, anything you’re paging through), reach for a generator, and accept the modest overhead in exchange for memory usage that doesn’t grow with the input. And if you’re chasing raw speed on a hot path and you’ve actually measured that it matters, the plain loop is still, quietly, the fastest thing PHP gives you. Don’t guess which of these applies: the benchmark above took about a dozen lines of code to write. Measure your own case if it matters enough to ask the question at all.
More About Composer and Packagist
You’ve used Composer since Chapter 7: composer install, a composer.json with an autoload block, PSR-4 mapping a namespace to a folder. That’s enough to build and structure a real project, and it’s most of what you’ll do with Composer day to day. It is not, however, everything Composer does, and this chapter fills in the rest of the picture: the parts you’ll reach for once your project grows past “one package, one repository.”
We’ll start close to home, with two features of composer.json you’ve been living next to without using: the "scripts" section, which turns shell commands into short, memorable Composer subcommands, and a second kind of autoloading, "files", for the plain function files that don’t fit PSR-4’s one-class-per-file assumption.
From there we widen out. Packagist is the public registry every composer require pulls from by default, and it’s worth understanding how a package actually gets there. Spoiler: nobody “uploads” anything. We’ll also look at what happens once you’re not working on a single package anymore: developing two or more local packages side by side, before either is published, using Composer’s path repositories, the mechanism that makes monorepo-style PHP projects work.
Finally, two things that live slightly outside any one project: installing command-line tools globally with Composer, so you have one copy of PHPStan or PHP-CS-Fixer available everywhere instead of a dozen per-project copies, and a brief, honest look at the automation hooks Composer offers around its own lifecycle, and the door it leaves open, via plugins, for going further than that.
Customizing Autoload and Scripts
The composer.json you’ve written so far has had two jobs: list dependencies, and map a namespace to a folder via PSR-4. It can do more than that with barely any extra effort, and two features are worth adding to your everyday habits right away: scripts, and a second flavor of autoloading for code that isn’t a class.
Scripts: shortcuts for commands you run constantly
Every PHP project accumulates a handful of commands you type over and over: running the test suite, running a linter, clearing a cache. Composer lets you name them, in the "scripts" section of composer.json:
{
"name": "you/phpgrep",
"require": {},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"scripts": {
"test": "phpunit",
"check": "phpstan analyse src"
}
}
Run either one with composer run, or, for a "scripts" entry with no colliding built-in Composer command, just composer followed by the name directly:
$ composer test
$ composer check
This isn’t just a shorter way to type phpunit. The real value shows up on a team: everyone runs composer test, regardless of whether the underlying tool is PHPUnit, Pest, or something else entirely, and regardless of what flags it needs. Change the command, and every developer, and every CI pipeline calling composer test, picks up the change automatically, with nothing to update on their end. A script entry can also be an array of commands, run in sequence, if a task genuinely needs more than one step:
{
"scripts": {
"check": [
"phpstan analyse src",
"phpunit"
]
}
}
"files" autoloading: for code that isn’t a class
PSR-4 autoloading, from Chapter 7, maps a namespace to a directory and loads classes from it on demand: one class, one file, found by name. That works perfectly for classes. It has nothing to say about a file full of plain functions, because there’s no class name for Composer to map to a file path. For that, composer.json has a second autoloading mechanism, "files", which just lists files to load unconditionally, every time the autoloader runs:
{
"autoload": {
"psr-4": {
"PhpGrep\\": "src/"
},
"files": [
"src/helpers.php"
]
}
}
Anything defined at the top level of src/helpers.php (functions, constants) becomes available everywhere in your project the moment vendor/autoload.php is included, with no use statement needed to reach it, the same way a built-in function like strtolower() needs none. This is the right tool for a small set of standalone helper functions that don’t belong to any particular class. It’s also easy to overuse: a "files" entry loads its file’s contents on every single request, unconditionally, unlike PSR-4 classes, which only load when something actually references them. Keep it for genuinely small, genuinely global helpers, and let PSR-4 handle everything that reasonably belongs on a class.
One last step, after editing either section by hand: run composer dump-autoload so Composer regenerates the autoloader files to match what you just wrote. composer install and composer require do this automatically; a manual edit to composer.json doesn’t, until you tell it to.
Publishing a Package to Packagist
Every composer require you’ve run has quietly relied on Packagist, the public package registry Composer checks by default whenever you ask it to install something. It’s the reason composer require nunomaduro/termwind in Chapter 1 didn’t need you to specify a URL, a server, or anything beyond a name: Packagist already knew where that package lived. Publishing your own package there is more approachable than it sounds, and understanding the mechanism removes a fair bit of mystery around what “publishing a PHP package” actually means.
What a publishable composer.json needs
At minimum, four things:
{
"name": "yourname/phpgrep",
"description": "A small line-searching CLI tool, built as a learning project.",
"license": "MIT",
"require": {
"php": ">=8.1"
},
"autoload": {
"psr-4": {
"PhpGrep\\": "src/"
}
}
}
"name" follows a fixed shape: vendor/package, both lowercase, hyphen-separated, where vendor is usually your GitHub username or organization, not a formal company name; plenty of published packages belong to individuals. "description" and "license" are what show up on your package’s Packagist page, and "license" matters for a practical reason too: without one, you’re leaving the terms under which anyone can use your code legally ambiguous, which is exactly the kind of thing that quietly scares off potential users. "MIT" is the common, permissive default if you have no particular reason to choose otherwise.
You don’t upload anything
This is the part that surprises people coming from ecosystems with a publish command. Composer packages aren’t uploaded to Packagist at all: Packagist doesn’t host your code. It hosts metadata about your code, and reads the actual source straight from your Git repository, GitHub included. Publishing a package is, mechanically:
- Push a
composer.jsonlike the one above to a public Git repository. - Go to packagist.org, sign in, and click “Submit,” pointing it at your repository’s URL.
- Packagist reads your
composer.json, indexes the package under the"name"you gave it, and, this is the important part, sets up a webhook so it’s notified automatically every time you push.
From that point on, there’s no separate “release” step, no build artifact to hand over. Packagist watches your repository directly.
Versions come from Git tags
If Packagist reads straight from your repository, where do version numbers like 1.2.0 come from? From ordinary Git tags, using semantic versioning: MAJOR.MINOR.PATCH:
$ git tag v1.0.0
$ git push origin v1.0.0
Push that tag, and Packagist’s webhook picks it up and lists 1.0.0 as an installable version within moments. Increment the patch number for backward-compatible fixes, the minor number for backward-compatible new features, and the major number the moment you break something a consumer might be relying on. That last rule is the one that actually matters to anyone depending on your package, since it’s what lets them write a version constraint like ^1.0 in their own composer.json and trust that anything satisfying it won’t break their code. There’s no separate step to “publish” 1.0.0 beyond tagging and pushing it; the tag is the release.
Composer Path Repositories and Monorepos
Publishing to Packagist, from the previous section, assumes your package is finished enough to hand to strangers. Plenty of real work happens before that point: specifically, the stretch where you’re developing two or more related packages together, and changes to one need to be visible in the other immediately, without a publish-and-reinstall cycle in between. Composer has a repository type built for exactly this: the path repository.
The problem it solves
Say you’re splitting phpgrep into two packages: a core library, phpgrep/core, and the CLI wrapper around it, phpgrep/cli, which depends on the core. Without publishing phpgrep/core anywhere, composer require phpgrep/core in the CLI package has nothing to install: Packagist has never heard of it, and neither has any other registry. You could publish an early, half-finished version just to unblock local development, but that’s backwards: you’d be publishing code for the sole purpose of testing it locally.
Pointing Composer at a local folder instead
A path repository tells Composer, for one project, “when you see this package name, don’t look on Packagist, look in this folder on disk instead”:
{
"repositories": [
{
"type": "path",
"url": "../phpgrep-core"
}
],
"require": {
"phpgrep/core": "*"
}
}
Given a directory layout like:
projects/
├── phpgrep-core/
│ └── composer.json ("name": "phpgrep/core")
└── phpgrep-cli/
└── composer.json (the file above)
running composer install inside phpgrep-cli resolves phpgrep/core to ../phpgrep-core and, by default, creates a symlink in vendor/phpgrep/core pointing back at the real folder, not a copy. Edit a file in phpgrep-core, and phpgrep-cli sees the change instantly, with no reinstall, no re-publish, nothing to run in between. It behaves, for local development purposes, exactly like a single package would, while still being two genuinely separate packages with their own composer.json, their own version constraints, and their own eventual path to Packagist once they’re ready.
Where this leads: monorepos
Take that same idea and put several related packages in one Git repository, each with its own composer.json, wired together with path repositories pointing at each other’s subfolders: that’s the shape most PHP monorepos take. There’s no special “monorepo mode” in Composer; a monorepo is just an ordinary directory tree of packages that happen to share one repository and use path repositories to reference each other during development. Some projects stay that way permanently, treating the monorepo as the real, shipped structure. Others use it purely as a development convenience and split packages out to their own repositories, and publish each to Packagist independently, once they’ve stabilized. Both are legitimate; which one fits depends more on your team’s release process than on anything Composer itself enforces.
Installing Global Tools with Composer
Not everything you install with Composer is a dependency of a specific project. Static analysis tools like PHPStan, code formatters like PHP-CS-Fixer, and similar command-line utilities are things you want available everywhere, run against whatever project you happen to be sitting in, not bundled into that project’s own composer.json. Composer has a separate command for exactly that case.
require --dev vs. global require
You’ve already used require-dev: a dependency needed only during development, like PHPUnit, listed in a project’s own composer.json and installed into that project’s own vendor/ folder:
$ composer require --dev phpunit/phpunit
That’s the right call for anything the project’s tests or build process genuinely depend on: everyone who clones the repository and runs composer install gets the same version, which matters for reproducibility. It’s the wrong call for a tool you personally like to run across every project you touch, regardless of what any one of them declares: installing PHPStan into ten different projects’ vendor/ folders, one copy per project, at one version per project, is a lot of duplication for a tool that doesn’t actually belong to any of them.
composer global require solves that:
$ composer global require phpstan/phpstan
This installs PHPStan once, into a global Composer directory entirely separate from any project (~/.config/composer on Linux, ~/.composer on macOS by default, though the exact path is worth confirming with composer global config home) rather than into a project’s vendor/ folder. From then on, phpstan is a single, shared install, available no matter which project directory you’re standing in.
Getting it on your PATH
A global install alone doesn’t make the phpstan command work from anywhere: your shell still needs to know where to find it. The binary lands in a vendor/bin folder inside that global Composer directory, and that folder needs to be on your PATH:
$ export PATH="$HOME/.composer/vendor/bin:$PATH"
Add that line to your shell’s startup file (~/.zshrc, ~/.bashrc, or equivalent) so it takes effect in every new terminal, not just the current one. Confirm it worked:
$ phpstan --version
PHPStan - PHP Static Analysis Tool 1.11.5
If that command isn’t found, the PATH line above is almost always the culprit: either it’s missing, pointing at the wrong directory for your platform, or you edited the startup file but haven’t reloaded it (source ~/.zshrc, or just open a new terminal).
Choosing between the two
The rule of thumb: if a tool needs to run identically for everyone on the team and in CI, at a version pinned in version control, it belongs in require-dev. If it’s a personal preference you run across every project regardless of what that project declares, and you’re comfortable it might drift a version or two out of sync with a teammate’s copy, composer global require is the better fit. Plenty of real setups use both at once: PHPStan pinned per-project via require-dev so CI is reproducible, and something like PHP-CS-Fixer installed globally for quick, ad hoc formatting while you’re editing.
Extending Composer with Scripts and Plugins
You’ve seen "scripts" used for commands you run yourself: composer test, composer check. Composer scripts have a second, quieter use: hooking into Composer’s own lifecycle, so something runs automatically at the right moment, without you having to remember to run it.
Lifecycle events
Composer fires a named event at each stage of its own work (before and after an install, before and after an update, and several others) and you can attach a script to any of them by name, instead of inventing a name of your own:
{
"scripts": {
"post-install-cmd": "@php artisan-like-thing:setup",
"post-update-cmd": [
"@php bin/generate-config.php"
]
}
}
post-install-cmd runs automatically every time someone runs composer install: a fresh clone getting its dependencies for the first time, a CI job setting up before tests, a new developer setting up their machine. That’s the point: nobody has to remember an extra manual step buried in a README, because Composer runs it for them, in the right order, every time. post-update-cmd is the equivalent hook for composer update. Other events exist for narrower moments (before a package is installed or removed, and so on) but post-install-cmd and post-update-cmd cover the overwhelming majority of real setups: regenerating a config file, warming a cache, printing a reminder about an environment variable that still needs setting.
The @php prefix runs a PHP script using the same PHP binary Composer itself is running under, which matters on machines with more than one PHP version installed: it removes any ambiguity about which php gets used.
Where scripts stop, and plugins start
Lifecycle scripts are shell commands (or PHP scripts) that Composer runs at fixed points: useful, but limited to “run this command when this event fires.” Sometimes that’s not enough: you want to change how Composer itself behaves, add a new command, alter how packages get installed, react to events with actual PHP logic instead of a fire-and-forget shell command. That’s what Composer plugins are for: ordinary Composer packages that hook into Composer’s internals directly, written in PHP, distributed and installed exactly like any other dependency. Tools you’ve likely encountered without realizing it (automatic .env file handling in some frameworks, for instance) are often implemented as Composer plugins under the hood.
Writing one is a legitimate thing to do, and worth knowing exists, but it’s real Composer-internals territory (event subscriber classes, Composer’s own plugin API) and squarely beyond what this book covers. If lifecycle scripts stop being enough for something you’re building, that’s the door to walk through next; the official Composer documentation is the right place to start.
Object-Oriented PHP
Chapter 5 gave you the basics of classes: properties, constructors, methods. Chapter 11 added interfaces and traits, PHP’s tools for sharing behavior across otherwise unrelated classes. Both were necessary groundwork, and neither one was the main event. This chapter is the main event: real object-oriented design, the kind you’ll actually use every day writing PHP, because it’s genuinely core to how the language and its ecosystem work: the frameworks you’ll eventually reach for, the libraries you’ll install, most of the code you’ll read that someone else wrote, all of it leans on the ideas in this chapter.
We’ll start with extends (one class building on another, overriding what it needs to while calling back into the parent’s own implementation with parent::method()) and with the payoff that makes inheritance worth the trouble in the first place: polymorphism, where code written against a general type works correctly on any specific subclass you hand it, without modification. From there we’ll go back to interfaces, this time putting them directly next to abstract classes and asking, honestly, when each one is the right tool: they solve overlapping problems, and knowing which to reach for is a real design skill, not a matter of taste.
The second half of the chapter covers PHP’s magic methods: a small set of specially named methods the language calls automatically in specific situations, letting an object behave like a string, or a function, or something with dynamic properties. Some of these are genuinely useful defaults you’ll reach for often. Others are powerful enough to make code harder to follow if you lean on them too heavily, and we’ll be direct about which is which.
We’ll close by building one classic design pattern from scratch, in idiomatic PHP, using nothing more exotic than the interfaces and polymorphism covered earlier in the chapter. Patterns get a reputation for being abstract and academic; seeing one assembled from pieces you already understand, solving a problem you’d actually run into, should put that reputation to rest.
Classes, Inheritance, and Polymorphism
You’ve defined classes since Chapter 5 and implemented interfaces since Chapter 11, but so far every class you’ve written has stood alone. Real designs usually involve classes that are variations on a theme: several kinds of the same basic idea, sharing some behavior and differing in the rest. That’s what inheritance is for.
extends and method overriding
A class can build on another with extends, inheriting its properties and methods and overriding whichever ones need to behave differently:
<?php
declare(strict_types=1);
class PaymentMethod
{
public function charge(float $amount): string
{
return sprintf('Charged $%.2f.', $amount);
}
}
class CreditCard extends PaymentMethod
{
public function __construct(private string $last4)
{
}
public function charge(float $amount): string
{
$base = parent::charge($amount);
return $base . " (card ending {$this->last4})";
}
}
CreditCard extends PaymentMethod means every CreditCard is a PaymentMethod, with all of its behavior, unless CreditCard explicitly overrides a method, which is exactly what its charge() does here, replacing the parent’s version with one that adds the card’s last four digits. parent::charge($amount) calls the original implementation from inside the override, rather than throwing it away entirely: the base class still does the generic formatting work; CreditCard just adds to it. Without parent::, you’d need to duplicate that sprintf() line in every subclass that wants it, which is exactly the kind of duplication inheritance exists to avoid.
A second subclass
Add another payment method the same way, overriding charge() with entirely different logic:
<?php
declare(strict_types=1);
class PayPal extends PaymentMethod
{
public function __construct(private string $email)
{
}
public function charge(float $amount): string
{
return sprintf('Charged $%.2f via PayPal account %s.', $amount, $this->email);
}
}
PayPal doesn’t call parent::charge() at all: nothing requires an override to reuse the parent’s implementation, only that it exist. Both CreditCard and PayPal fully replace the base behavior with their own, which is a perfectly normal use of inheritance: sharing the contract (“every PaymentMethod can charge()”) without necessarily sharing any code.
Polymorphism: the actual payoff
Here’s why any of this was worth setting up. Write code against the base type, PaymentMethod, and hand it any subclass: it works, without the calling code knowing or caring which one it actually got:
<?php
declare(strict_types=1);
function processPayment(PaymentMethod $method, float $amount): void
{
echo $method->charge($amount) . "\n";
}
$methods = [
new CreditCard('4242'),
new PayPal('damien@example.com'),
];
foreach ($methods as $method) {
processPayment($method, 42.00);
}
$ php payments.php
Charged $42.00. (card ending 4242)
Charged $42.00 via PayPal account damien@example.com.
processPayment() is typed against PaymentMethod, never against CreditCard or PayPal specifically, and the foreach loop above treats every element identically even though each one runs completely different code when charge() is called. That’s polymorphism: the same call, $method->charge($amount), does the right thing for whatever concrete object is actually behind $method at runtime. Add a third payment method next month (BankTransfer, Cryptocurrency, whatever the product needs), and as long as it extends PaymentMethod and implements charge(), processPayment() and the foreach loop above need no changes at all. They were never written against a specific class in the first place, only against the shape every PaymentMethod is guaranteed to have.
This should feel familiar: it’s the same idea as programming against an interface, from Chapter 11, and for good reason: interfaces and inheritance are two different roads to the same destination, polymorphic code that doesn’t need to know which concrete class it’s holding. The next section puts them side by side and asks, directly, when to reach for which one.
Abstract Classes and Interfaces Revisited
The PaymentMethod base class from the previous section had a working charge() implementation of its own: generic, but real. That’s a design smell worth noticing: nothing stops anyone from writing new PaymentMethod() directly and calling charge() on a payment method that isn’t actually connected to a card, an account, or anything else capable of processing money. The base class was only ever meant as a foundation for subclasses, never as something to instantiate on its own, but PHP had no way of knowing that. abstract is how you tell it.
Making the contract explicit
<?php
declare(strict_types=1);
abstract class PaymentMethod
{
abstract public function charge(float $amount): string;
protected function receipt(float $amount): string
{
return sprintf('$%.2f processed on %s', $amount, date('Y-m-d'));
}
}
class CreditCard extends PaymentMethod
{
public function __construct(private string $last4)
{
}
public function charge(float $amount): string
{
return $this->receipt($amount) . " (card ending {$this->last4})";
}
}
Two things changed. PaymentMethod is now abstract class PaymentMethod, which means PHP refuses to let you instantiate it directly: new PaymentMethod() is a fatal error, full stop, enforced by the language rather than left as a convention you hope people follow. And charge() is declared abstract public function charge(float $amount): string; (a signature with no body, exactly like an interface method) which means every non-abstract subclass must implement it, or PHP refuses to load that subclass too. What PaymentMethod still has is receipt(), a real, shared, working method that every subclass inherits for free. That’s the combination an abstract class gives you that a plain interface can’t: an enforced contract (charge() must exist) bundled with genuine shared implementation (receipt(), written once, used everywhere).
Compare this to Formattable
Go back to the Formattable interface from Chapter 11:
<?php
interface Formattable
{
public function format(): string;
}
An interface is only a contract: no method bodies are allowed at all, not even ones a class could optionally inherit. Every class implementing Formattable writes its own format() from scratch; there’s no shared code to lean on, because an interface has none to offer. That’s not a limitation so much as the point: interfaces exist to describe a capability that classes with nothing else in common can all claim, without dragging in any shared ancestry. A Product, a LogEntry, and an HttpResponse could all reasonably implement Formattable despite having nothing else to do with one another, and a class can implement as many interfaces as it needs, which is exactly how PHP works around not supporting multiple inheritance for classes. An abstract class, by contrast, is a real ancestor: a class can only extends one, and everything that abstract class carries (properties, working methods, constructor logic) comes along with it.
When to reach for which
The honest rule: reach for an abstract class when you have real implementation code you want every subclass to share, and you want to force each subclass to fill in the specific parts that must differ: receipt() shared, charge() mandatory but unique per subclass, in the example above. Reach for an interface when all you want is a guarantee that a method exists, with no assumption that the implementing classes are related to each other at all, or when a class already needs to extends something else and still needs to promise a second, unrelated capability: a class can only have one parent, but as many interfaces as it likes.
They’re not actually rivals, and PHP doesn’t make you pick exactly one. A class can extend an abstract parent and implement an interface at the same time:
<?php
declare(strict_types=1);
interface Formattable
{
public function format(): string;
}
abstract class PaymentMethod implements Formattable
{
abstract public function charge(float $amount): string;
public function format(): string
{
return static::class;
}
}
PaymentMethod gets both: the shared, enforced structure of an abstract class for its own family of subclasses, and a separate, unrelated Formattable contract that lets it interoperate with any other code in the system that only cares whether something can format(), no relationship to PaymentMethod required.
Magic Methods
PHP calls a small set of specially named methods automatically, in response to specific situations, rather than waiting for you to call them by name: using an object in a string, reading a property that doesn’t exist, calling a method that isn’t there. These are magic methods, always prefixed with a double underscore, and you’ve already met the first two.
__construct and __destruct, briefly
__construct() has been running quietly under every new you’ve written since Chapter 5: it’s the method PHP calls automatically when an object is created, and it’s where constructor promotion does its work. __destruct() is its counterpart: PHP calls it automatically when an object is about to be destroyed, typically when the last variable referencing it goes out of scope. You’ll see it far less often; most PHP objects don’t need cleanup logic, since PHP’s garbage collector, covered in Chapter 4, handles memory on its own. __destruct() earns its keep mainly for things like closing a file handle or a network connection explicitly, rather than waiting for the process to end.
__toString(): letting an object act like a string
This is the magic method you’ll reach for most often. Define it, and PHP will call it automatically anywhere your object is used in a string context: string concatenation, interpolation, a plain echo:
<?php
declare(strict_types=1);
final class Money
{
public function __construct(
private int $cents,
private string $currency,
) {
}
public function __toString(): string
{
return sprintf('%.2f %s', $this->cents / 100, $this->currency);
}
}
$price = new Money(4999, 'USD');
echo "Total: {$price}\n";
echo 'Total: ' . $price . "\n";
$ php money.php
Total: 49.99 USD
Total: 49.99 USD
Neither line calls format() or anything else by name; PHP sees $price land in a string context and calls __toString() on its own. This genuinely earns its place: any class representing something that has an obvious, sensible textual form (money, a name, an identifier) is a good candidate. Note the return type must be string; __toString() returning anything else is a fatal error.
__get and __set: dynamic property access
These fire when code reads or writes a property that isn’t declared on the class at all:
<?php
declare(strict_types=1);
final class Config
{
private array $values = [];
public function __get(string $name): mixed
{
return $this->values[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
$this->values[$name] = $value;
}
}
$config = new Config();
$config->debug = true;
var_dump($config->debug); // true
var_dump($config->unset_key); // null
$config->debug = true looks like a normal property write. Config has no $debug property, so PHP calls __set('debug', true) instead, which stores the value in the internal $values array. Reading $config->debug back triggers __get('debug') the same way. This can be a genuinely convenient way to build something that behaves like a flexible bag of properties.
It’s also worth being honest about the cost. Code that reads $config->debug gives no hint, at the call site, of where that value actually comes from or whether it exists: your editor can’t autocomplete it, and a static analysis tool like PHPStan, from Chapter 11, can’t verify it the way it can verify a real declared property. Overuse __get/__set and you’ve traded a small amount of boilerplate for code that’s harder for both humans and tools to follow. Use them sparingly, for cases where the dynamic behavior is genuinely the point (a config bag, a data-transfer object wrapping an unpredictable external shape), not as a general substitute for declaring real properties.
__call: intercepting method calls
__call() is the method equivalent of __get/__set: it fires when code calls a method that doesn’t exist on the object:
<?php
declare(strict_types=1);
final class Logger
{
public function __call(string $name, array $arguments): void
{
$level = strtoupper($name);
echo "[{$level}] {$arguments[0]}\n";
}
}
$logger = new Logger();
$logger->warning('Disk space is low.');
$logger->error('Connection refused.');
$ php logger.php
[WARNING] Disk space is low.
[ERROR] Connection refused.
Logger has no warning() or error() method at all; every call to a missing method lands in __call(), with the method name and its arguments handed to you as string and array. This is a real technique, it’s how some libraries build fluent, flexible-looking APIs, but it comes with the same honest caveat as __get/__set, doubled: nothing about Logger’s class definition tells you warning() or error() exist, or what they accept. Reach for it when the flexibility is worth that cost; otherwise, a handful of ordinary, explicitly declared methods will almost always serve your reader, and your tooling, better.
Implementing a Classic OOP Design Pattern
A design pattern is just a name for a shape of code that shows up often enough, across enough different problems, that it’s worth recognizing on sight. You’ve actually been building most of one already, across the last three sections: the PaymentMethod family. This section finishes the job and names what you’ve built: the Strategy pattern, one of the most common in all of object-oriented programming, and one you’ll recognize instantly in other people’s code once you’ve written it yourself.
The idea
Strategy’s whole premise: take a piece of behavior that can vary (how a payment gets charged, how a list gets sorted, how a price gets discounted), pull it out behind a shared interface, and hand it to a class that uses that behavior without needing to know which specific version it received. That last part should sound familiar; it’s exactly the polymorphism from ch17-01. Strategy just adds one more piece: a dedicated class, usually called the context, whose entire job is holding onto a strategy and delegating to it, and letting that strategy be swapped out, even after the context already exists.
Building it
Back to an interface, not an abstract class this time: there’s no shared implementation code worth forcing on every payment method, only a contract, which is exactly the case ch17-02 said an interface fits best:
<?php
declare(strict_types=1);
interface PaymentMethod
{
public function charge(float $amount): string;
}
final class CreditCard implements PaymentMethod
{
public function __construct(private string $last4)
{
}
public function charge(float $amount): string
{
return sprintf('Charged $%.2f to card ending %s.', $amount, $this->last4);
}
}
final class PayPal implements PaymentMethod
{
public function __construct(private string $email)
{
}
public function charge(float $amount): string
{
return sprintf('Charged $%.2f via PayPal account %s.', $amount, $this->email);
}
}
Nothing new here: this is the same pair of classes from earlier in the chapter, just implementing an interface instead of extending an abstract base. Now the context:
<?php
declare(strict_types=1);
final class Checkout
{
public function __construct(private PaymentMethod $paymentMethod)
{
}
public function setPaymentMethod(PaymentMethod $paymentMethod): void
{
$this->paymentMethod = $paymentMethod;
}
public function complete(float $amount): void
{
echo $this->paymentMethod->charge($amount) . "\n";
}
}
Checkout is the context. It holds a PaymentMethod (any PaymentMethod), and its complete() method delegates entirely to whatever strategy it’s currently holding, without a single if statement checking which one it is. That absence of branching is the tell that you’re looking at Strategy done properly: Checkout never asks “are you a CreditCard or a PayPal?” It just calls charge() and trusts the interface.
Using it and swapping strategies at runtime
<?php
$checkout = new Checkout(new CreditCard('4242'));
$checkout->complete(42.00);
$checkout->setPaymentMethod(new PayPal('damien@example.com'));
$checkout->complete(19.99);
$ php checkout.php
Charged $42.00 to card ending 4242.
Charged $19.99 via PayPal account damien@example.com.
Same $checkout object, same complete() call, two completely different outcomes, because setPaymentMethod() swapped out the strategy in between. That’s the part a plain if ($type === 'credit_card') branch scattered through Checkout could never give you as cleanly: adding a third payment method next quarter means writing one new class that implements PaymentMethod, and changing nothing whatsoever in Checkout itself. It already works, for the same reason processPayment() already worked back in ch17-01: it was never written against a specific strategy in the first place, only against the interface every strategy is guaranteed to satisfy.
That’s the entire pattern: an interface describing a swappable behavior, one or more classes implementing it, and a context class that delegates to whichever one it’s holding. No new syntax, no library, nothing PHP-specific about it at all: just the interfaces and polymorphism you already had, arranged on purpose to solve a recognizable problem. Once you’ve built one pattern this way, you’ll start noticing the same shape everywhere, under other names, in code you didn’t write.
Concurrency in PHP: A Brief Tour
Here’s an unusual thing to say in a programming book: you can skip this chapter, come back to it in a year, and lose nothing. Most PHP developers write years of production code, real code, serving real traffic, without ever touching a thread, a fiber, or a process fork. That’s not a gap in their skills. It’s how the language was designed to be used, and for the overwhelming majority of PHP work, from small business sites to large e-commerce platforms, it’s still the right way to work.
That makes this chapter different from almost every other one in this book. Elsewhere, I’ve been telling you “this is something you’ll use constantly, learn it well.” Here, I’m telling you the opposite: this is an advanced, optional tour. Read it to understand why PHP behaves the way it does and what your options are when you eventually hit a case that needs more, not because you need any of it to write ordinary PHP applications.
So why include it at all? Because sooner or later you’ll wonder why PHP doesn’t have threads the way Java or C# does, or you’ll need to send a welcome email without making the user wait for it. This chapter answers those questions at a level that lets you hold an intelligent conversation about them, and know what to search for when the day comes that you actually need more.
We’ll look at two things: the request model that made explicit concurrency largely unnecessary in traditional PHP, and the everyday tools (queues, background processes) that PHP developers actually reach for when work needs to happen outside the request/response cycle.
The PHP Request Model: Why PHP Is (Usually) Single-Threaded
If you’ve used Node.js or a Java application server before, you’re used to a program that starts once, stays running, and handles every request that arrives while it’s alive. State lives in memory between requests. A variable set while handling one user’s request can, if you’re not careful, still be sitting there when the next user’s request comes in.
PHP, in its classic and still most common form, doesn’t work that way. Every HTTP request gets a fresh start: the PHP process (or thread, depending on how your web server is configured) loads your script, runs it from the top, sends a response, and then throws everything away. Every variable, every object, every static property: gone. The next request, even the very next one, starts from absolute zero. Nothing is shared between requests except what you’ve deliberately put somewhere external: a database, a file, a cache like Redis or Memcached.
This is usually called a shared-nothing architecture, and it’s the single biggest structural difference between PHP and languages built around long-running server processes. It’s baked into how PHP is typically run: under PHP-FPM (the FastCGI Process Manager, the standard way to run PHP behind nginx or Apache in production), a pool of PHP worker processes sits ready, and each incoming request is handed to one of them for exactly as long as it takes to produce a response. When you ran php hello.php back in Chapter 1, you saw the same lifecycle in miniature: the interpreter starts, runs your script top to bottom, and exits.
Why this made threading unnecessary
Threads exist to let one running program do several things at once, safely, while sharing memory. But if your “program” only ever handles one request from start to finish and then vanishes, there’s rarely anything to make concurrent within it. The concurrency PHP applications need, handling thousands of simultaneous users, is handled a level up, by running many PHP processes side by side, not by making a single PHP process juggle many things internally. Your web server and process manager are already doing the hard part.
This has real, practical upsides. You don’t need to reason about race conditions inside a single request the way you would in a multi-threaded Java servlet: two users can’t corrupt each other’s $_SESSION data by both writing to the same variable at once, because there is no shared variable; each gets an entirely separate execution. Entire categories of bugs that plague long-running, shared-memory server processes simply don’t arise in classic PHP, because the model rules them out from the start rather than asking you to avoid them through discipline.
Where it stops being the whole story
None of this means PHP can’t share state or run things concurrently; it means that, by default, it doesn’t, and traditional PHP web applications were designed around that constraint rather than fighting it. A few things are worth flagging as exceptions, which we’ll return to shortly:
- Work that needs to happen but shouldn’t hold up the response (sending an email, resizing an uploaded image) is typically pushed to a separate process, not run inline.
- Long-running PHP processes do exist: command-line daemons, queue workers, and newer tools like Swoole servers keep a process alive across many units of work, and there the shared-nothing guarantee no longer applies automatically.
- Opcache, PHP’s bytecode cache, does share compiled code across requests for performance, but that’s compiled code, not your application’s runtime state.
The next section looks at how PHP applications actually get concurrent-ish work done in practice, without ever needing a thread.
Background Work with Queues and Processes
So a request comes in, PHP handles it, and the process disappears when the response is sent. That’s fine for “look up this user and render their profile.” It’s a problem for “resize this uploaded image, generate three thumbnails, and email a confirmation”: nobody wants to stare at a spinner for eight seconds because your code is doing image processing before it can say “Upload successful.” The user doesn’t need to wait for that work to finish. They just need to know it’s been accepted.
The standard PHP answer is: don’t do it now. Do it later, in a different process.
Job queues
The pattern looks like this: instead of doing the slow work inline, the request handler packages up what needs to happen, “resize image #482 for user #17,” as a small message, and pushes that message onto a queue. Then it responds to the user immediately: “Upload received, processing.” Meanwhile, one or more separate PHP processes, called workers, sit in a loop watching that queue. As soon as a message appears, a worker picks it up and does the actual work, entirely disconnected from the original request.
The queue itself is usually backed by something built for exactly this job: Redis is a common, lightweight choice; RabbitMQ and Amazon SQS show up in larger systems. PHP frameworks like Laravel and Symfony ship queue abstractions on top of these so you’re not hand-rolling the plumbing, but the underlying idea is simple enough that you could build a crude version yourself with nothing more than a database table and a SELECT ... WHERE processed = false.
The workers are ordinary PHP, run from the command line, typically kept alive by a process supervisor:
$ php worker.php
Waiting for jobs...
Processing job: resize-image #482
Done.
Waiting for jobs...
That worker script loops indefinitely, checking the queue, handling whatever it finds, and looping again: a long-running PHP process, which is exactly the kind of thing that steps outside the shared-nothing model from the previous section. It keeps state (a database connection, maybe a cache of configuration) across many jobs, the way a Node.js server would across many requests.
Spinning up a separate process directly
Queues are the right tool when you have many small units of work arriving over time. Sometimes what you actually want is simpler: “run this other program right now, and don’t wait around for it, or wait, but let it run alongside something else I’m doing.” For that, PHP can launch operating-system processes directly.
proc_open() is the general-purpose tool for this: it starts an external command (which might itself be another PHP script) and gives you handles to its input, output, and error streams, so you can talk to it while it runs. It’s what powers things like Composer’s own internal process handling.
There’s also the pcntl extension, which lets a PHP script fork itself into multiple copies (pcntl_fork()), genuinely running PHP code in parallel, as separate OS processes, each with its own memory. It’s powerful and, honestly, a little unforgiving: forking is only available on Unix-like systems, not Windows, and reasoning about multiple processes correctly takes real care. It shows up in command-line tools and daemons more than in web applications.
Both are worth knowing exist. Neither is something you should reach for before a job queue, which solves the same underlying problem, “run this later, not now,” with far less to get wrong.
Patterns and Matching
You already know match: Chapter 6 paired it with enums and showed why it’s usually the right replacement for switch. This chapter isn’t a rerun of that. It’s about PHP’s other pattern-shaped tool, one that gets far less attention than it deserves: destructuring, the art of pulling several values out of an array in one assignment instead of fetching them one at a time.
Destructuring and match don’t look much alike on the page, but they’re solving related problems. Both are about taking a shape you have (an array, a value that could be one of several things) and pulling structure out of it directly, rather than writing out the indexing or comparison logic by hand. Once you’ve used destructuring for a while, reaching into an array with $row[0], $row[1], $row[2] on three separate lines starts to feel as dated as a switch statement with six breaks.
We’ll survey the places destructuring shows up (plain assignment, foreach, skipped elements), then go deeper on the array syntax itself, including nested and keyed destructuring, which is where it earns its keep in everyday code. We’ll close with a few syntax details of match that Chapter 6 didn’t have room for: multiple conditions per arm, and the fact that arms are full expressions, not just literals.
None of this is exotic PHP. It’s ordinary, idiomatic code that you’ll start reaching for constantly once it’s in your hands: the kind of thing that makes code you write next week noticeably tidier than code you wrote last week.
Where match and Destructuring Can Be Used
Destructuring is assignment that unpacks. Instead of assigning one value to one variable, you assign several values from an array to several variables, in a single statement, by describing the shape you expect on the left-hand side. It shows up in more places than you’d guess once you start looking for it.
The two spellings
The original syntax uses list():
<?php
$coordinates = [4, 7];
list($x, $y) = $coordinates;
echo "x={$x}, y={$y}\n";
PHP later added a shorter form using square brackets, which does exactly the same thing and is the one you’ll see in modern code:
<?php
$coordinates = [4, 7];
[$x, $y] = $coordinates;
echo "x={$x}, y={$y}\n";
Both forms match values positionally: the first element of the array goes to the first variable named, the second to the second, and so on. list() still appears in older codebases and a handful of PHP’s own documentation examples, so it’s worth recognizing, but there’s no reason to reach for it in new code: the bracket form is shorter and reads the same.
Inside a foreach
Destructuring becomes genuinely useful when you combine it with foreach, unpacking each element of a collection as you iterate:
<?php
$pairs = [
['Alice', 30],
['Bob', 25],
['Carol', 35],
];
foreach ($pairs as [$name, $age]) {
echo "{$name} is {$age} years old.\n";
}
$ php pairs.php
Alice is 30 years old.
Bob is 25 years old.
Carol is 35 years old.
Without destructuring, you’d write $pair[0] and $pair[1] inside the loop body: it works, but it tells the reader nothing about what those positions mean. foreach ($pairs as [$name, $age]) documents the shape of the data right there in the loop header.
Skipping elements
Sometimes you only want some of the values an array offers. Leaving a slot empty skips it, without shifting the positions of the ones you do want:
<?php
$row = [1, 'Second', 'Third'];
[, $second, $third] = $row;
echo "{$second}, {$third}\n"; // Second, Third
The leading comma with nothing before it says “skip the first element”: the variable list has a gap where a name would normally go. This is a small thing, but it reads better than assigning a value to $unused you’ll never touch.
That covers where destructuring shows up. The next section goes deeper into the array-unpacking syntax itself: nesting, matching by key instead of position, and a genuinely useful one-liner for swapping two variables.
List and Array Destructuring
The previous section showed destructuring in its simplest, flat form. Arrays are rarely that tidy in real code: they nest, and they’re often associative rather than positional. Destructuring handles both.
Nested destructuring
If an array contains other arrays, the destructuring pattern can mirror that shape directly:
<?php
$point = [[1, 2], 3];
[[$x, $y], $z] = $point;
echo "x={$x}, y={$y}, z={$z}\n"; // x=1, y=2, z=3
The pattern on the left literally looks like the data on the right: [[$a, $b], $c] next to [[1, 2], 3]. That symmetry is the whole appeal: once an array’s shape gets more than one level deep, destructuring lets you say “give me exactly this shape” in one line instead of chaining index access like $point[0][0].
Keyed destructuring
Positional unpacking works fine for tuples like coordinates, but most arrays you’ll destructure in real applications are associative: rows from a database, decoded JSON, form input. For those, match by key instead of position:
<?php
$userData = [
'name' => 'Priya',
'age' => 29,
'email' => 'priya@example.com',
];
['name' => $name, 'age' => $age] = $userData;
echo "{$name} is {$age}.\n"; // Priya is 29.
Notice email is simply ignored: you only need to name the keys you actually want, and the rest of the array is left alone. This is where destructuring stops being a shorthand and starts being genuinely more readable than the alternative: compare ['name' => $name, 'age' => $age] = $userData; to two separate lines of $userData['name'] and $userData['age']. The keyed form also self-documents which fields a function actually cares about, right at the point of use.
You can combine keyed and nested destructuring too:
<?php
$response = [
'status' => 'ok',
'user' => ['name' => 'Priya', 'age' => 29],
];
['user' => ['name' => $name, 'age' => $age]] = $response;
echo "{$name}, {$age}\n"; // Priya, 29
Swapping two variables
Destructuring has one small, satisfying party trick: swapping the values of two variables without a temporary third one.
<?php
$a = 1;
$b = 2;
[$a, $b] = [$b, $a];
echo "a={$a}, b={$b}\n"; // a=2, b=1
PHP builds the array [$b, $a] on the right-hand side first (capturing both original values) and only then assigns into $a and $b on the left. That ordering is exactly what makes the swap safe: by the time $a gets overwritten, $b’s original value has already been read. It’s a small idiom, but it’s the kind of line that makes you look like you know the language, and it genuinely is the cleanest way to swap two values in PHP: no $temp variable required.
A word of caution
Destructuring an array that doesn’t have the keys or positions you expect doesn’t throw: missing elements just become null, with a warning in strict error-reporting setups. It’s a pattern match in shape only; PHP won’t stop you from destructuring a three-element array as if it had five. Treat it as a convenience for code where you already trust the shape of the data; validate first, when the data comes from outside your control.
match Pattern Syntax
Chapter 6 introduced match alongside enums, where it earns its keep the most. Before we leave match behind for good, three syntax details are worth pinning down: they don’t come up in the simplest examples, but you’ll want all three the first time you write a match expression with more than two or three arms.
Multiple conditions per arm
An arm doesn’t have to test a single value. Separate several with commas, and the arm matches if any of them equals the subject:
<?php
$dayNumber = 6;
$dayType = match ($dayNumber) {
1, 2, 3, 4, 5 => 'Weekday',
6, 7 => 'Weekend',
default => 'Invalid',
};
echo $dayType; // Weekend
Read the comma as “or”: 1, 2, 3, 4, 5 => means “if the subject is 1, or 2, or 3, or 4, or 5.” Without this, you’d need five separate arms all returning the same value, which is exactly the kind of repetition match exists to eliminate.
Order matters: first match wins
match checks its arms from top to bottom and stops at the first one that matches. That’s not usually something you have to think about, because well-designed conditions don’t overlap. But combine match (true) (matching against boolean conditions rather than a single value, as you saw back in Chapter 3) with conditions that can overlap, and order becomes a real decision, not a formality:
<?php
$score = 85;
$grade = match (true) {
$score >= 90 => 'A',
$score >= 80 => 'B',
$score >= 70 => 'C',
default => 'F',
};
echo $grade; // B
Put $score >= 70 first, and every score of 85 or above would also satisfy it, and you’d never reach the A or B arms at all; they’d be unreachable code, silently. Ordering the conditions from most specific to least specific, as above, is what makes this pattern work. It’s a small trap, but a common one: when your arms can overlap, always order the most restrictive condition first.
Arms are expressions, not just values
Every arm of a match is a full expression, evaluated and returned when that arm is chosen: it doesn’t have to be a bare literal. You can call a function, construct an object, or run any expression PHP allows:
<?php
enum LogLevel
{
case Info;
case Warning;
case Error;
}
function formatMessage(string $level, string $text): string
{
return "[" . strtoupper($level) . "] {$text}";
}
$level = LogLevel::Warning;
$output = match ($level) {
LogLevel::Info => formatMessage('info', 'Request completed'),
LogLevel::Warning => formatMessage('warning', 'Disk usage above 80%'),
LogLevel::Error => (new RuntimeException('Disk full'))->getMessage(),
};
echo $output; // [WARNING] Disk usage above 80%
That last arm constructs an exception object and immediately calls a method on it, all inside a single match arm; parentheses around new RuntimeException(...) are needed there so PHP knows to call getMessage() on the constructed object rather than trying to parse it some other way. There’s no rule that arms have to be short or trivial; they just have to be expressions, which in PHP covers almost everything. This is what makes match a genuine replacement for a lot of small helper functions, not just a tidier switch.
Advanced Features
This chapter is a toolbox, not a narrative. The four sections that follow don’t build on each other the way most of this book has: each one covers a self-contained corner of PHP that you’ll reach for occasionally, not daily. None of it is exotic or rare in the ecosystem; you’ll see all of it in frameworks, in libraries you install with Composer, and in code written by developers who’ve been doing this for a while. But it’s also not code you’ll write every day, which is exactly why it’s grouped here near the end rather than woven earlier through the book.
We’ll look at magic constants and Reflection: PHP’s ability to inspect its own classes and methods at runtime, which you’ll use directly less often than you’d think, because frameworks and testing tools mostly use it on your behalf. Then the built-in SPL interfaces that let your own objects plug into PHP’s syntax, making count(), array-bracket access, and foreach work on objects you designed yourself. Then a modern look at closures and callables, including the clean first-class callable syntax PHP 8.1 introduced. And finally, attributes: structured metadata attached directly to your code, the language-native replacement for what used to live only in comments.
Treat this chapter the way you’d treat a drawer of specialty tools in a workshop: you won’t need most of them most days, but when the right job comes along, knowing the tool exists, and roughly how it works, is most of the battle.
Magic Constants and Reflection
Most of the time, your code knows exactly what it is: you wrote it, you know the class name, the method name, the file it lives in. But sometimes code needs to ask itself questions at runtime: “what class am I currently in?”, “what methods does this object have?”, usually for logging, debugging, or building generic tools that operate on classes they’ve never seen before. PHP gives you two very different tools for that: a handful of constants that answer simple questions cheaply, and Reflection, a full API for interrogating your code’s structure in detail.
Magic constants
PHP defines several constants that expand, at compile time, to information about where they appear in your source. They’re called “magic” because their value depends entirely on context: the same __LINE__ means something different in every file:
<?php
class Logger
{
public function warn(string $message): void
{
echo __CLASS__ . '::' . __FUNCTION__ . " at line " . __LINE__ . ": {$message}\n";
}
}
(new Logger())->warn('Disk space low');
$ php logger.php
Logger::warn at line 7: Disk space low
__CLASS__ gives you the current class name, __FUNCTION__ the current function or method name (__METHOD__ gives you both, as Class::method), __LINE__ the current line number, and __FILE__ the full path of the current file. None of them do anything clever, they’re just filled in by the parser before your code runs, but that makes them cheap, reliable, and exactly what you want for log lines and debug output where you need to know where a message came from without hardcoding it.
Reflection
Magic constants tell code about itself. Reflection lets code inspect other code: classes, methods, properties, parameters, even attributes (which you’ll meet later in this chapter), as data you can query at runtime, rather than something only visible while reading source.
<?php
class UserRepository
{
public function find(int $id): ?string
{
return "User #{$id}";
}
public function save(string $name): void
{
// ...
}
private function connect(): void
{
// ...
}
}
$reflection = new ReflectionClass(UserRepository::class);
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
echo $method->getName() . "\n";
}
$ php reflect.php
find
save
ReflectionClass wraps a class and exposes everything about its shape: getMethods(), getProperties(), getConstructor(), and more, each returning further reflection objects (ReflectionMethod, ReflectionProperty) you can query in turn: a method’s parameters, their types, whether a property is readonly, and so on. Passing ReflectionMethod::IS_PUBLIC above filters out connect(), PHP’s private helper method, leaving just the public interface.
Where you’ll actually meet this
Be honest with yourself about how often you’ll write code like the example above: rarely. What Reflection is for, in practice, is enabling other tools to work generically. A dependency injection container uses Reflection to look at a constructor’s parameters and figure out what to pass in. PHPUnit uses it to find your test methods. Laravel and Symfony lean on it constantly under the hood. You’ll use Reflection indirectly, through frameworks and libraries, far more often than you’ll call new ReflectionClass(...) yourself, but knowing it’s there, and roughly how it works, makes the “magic” those tools perform a lot less mysterious.
Built-in Interfaces: Countable, ArrayAccess, IteratorAggregate
Back in Chapter 11 you learned that an interface is a contract: implement its methods, and your class can be used anywhere that contract is expected. PHP takes that idea one step further with a small set of built-in interfaces, part of the SPL (Standard PHP Library), that don’t just describe a contract for your own code: they plug your objects directly into PHP’s own syntax. Implement one, and ordinary language constructs like count(), $object['key'], or foreach start working on your object as if it were a native array.
Countable
Implement count(): int, and PHP’s built-in count() function will call it for you:
<?php
class Playlist implements Countable
{
private array $tracks = [];
public function add(string $track): void
{
$this->tracks[] = $track;
}
public function count(): int
{
return count($this->tracks);
}
}
$playlist = new Playlist();
$playlist->add('Track One');
$playlist->add('Track Two');
echo count($playlist); // 2
Nothing here is more powerful than just calling $playlist->count() directly, but count($playlist) reads as “this behaves like a collection,” which is exactly the impression you want to give the next person using your class.
ArrayAccess
ArrayAccess is the more dramatic one: implement its four methods, and square-bracket syntax works on your object.
<?php
class Config implements ArrayAccess
{
private array $values = [];
public function offsetExists(mixed $offset): bool
{
return isset($this->values[$offset]);
}
public function offsetGet(mixed $offset): mixed
{
return $this->values[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->values[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->values[$offset]);
}
}
$config = new Config();
$config['debug'] = true;
echo $config['debug'] ? "on\n" : "off\n"; // on
echo isset($config['missing']) ? "yes\n" : "no\n"; // no
offsetSet backs $config['debug'] = true, offsetGet backs reading $config['debug'], offsetExists backs isset($config[...]), and offsetUnset backs unset($config[...]). Underneath, Config is still an ordinary object with an ordinary private array; ArrayAccess just lets the outside world address it with array syntax, which is a genuinely nice fit for something like a configuration object or a typed collection wrapper.
IteratorAggregate
The third makes your object work directly in a foreach. Rather than implementing iteration logic yourself, IteratorAggregate asks for a single method, getIterator(), that hands back something already iterable, usually a Generator (from Chapter 15):
<?php
class Playlist implements IteratorAggregate
{
private array $tracks = [];
public function add(string $track): void
{
$this->tracks[] = $track;
}
public function getIterator(): Generator
{
foreach ($this->tracks as $track) {
yield $track;
}
}
}
$playlist = new Playlist();
$playlist->add('Track One');
$playlist->add('Track Two');
foreach ($playlist as $track) {
echo "{$track}\n";
}
$ php playlist.php
Track One
Track Two
That last foreach doesn’t know or care that $playlist isn’t a plain array; it just works, because IteratorAggregate told PHP where to find the values. There’s also a lower-level Iterator interface, with methods like current(), next(), and valid(), for cases where you need finer control over iteration state; IteratorAggregate is the one you’ll reach for almost always, since it lets a Generator do the bookkeeping for you.
Together these three interfaces are how you make a custom object feel native (indistinguishable, at the call site, from an array) while keeping whatever internal structure and validation your class actually needs.
First-Class Callable Syntax and Advanced Closures
You met closures and arrow functions in Chapter 15. This section covers two more recent additions worth having in your toolkit: a cleaner syntax for turning existing functions and methods into callables, and a couple of Closure tricks that come up once you’re writing more deliberate, defensive code.
The old way of passing a function around
Before PHP 8.1, if you wanted to pass an existing function or method as a value (to array_map(), for instance) you reached for a string or an array:
<?php
$lengths = array_map('strlen', ['a', 'bb', 'ccc']);
class Greeter
{
public function greet(string $name): string
{
return "Hello, {$name}!";
}
}
$greeter = new Greeter();
$greetCallable = [$greeter, 'greet'];
echo $greetCallable('Sam'), "\n"; // Hello, Sam!
This works, and you’ll still see it in plenty of existing code, but it has a real downside: 'strlen' and [$greeter, 'greet'] are just a string and an array as far as your tools are concerned. Your editor can’t reliably jump to the definition, and a typo in the method name isn’t caught until the callable is actually invoked.
First-class callable syntax
PHP 8.1 added a direct syntax for the same thing: write the function or method’s name followed by (...) (three literal dots, not a real argument list) and PHP hands you a proper Closure pointing at it.
<?php
$lengths = array_map(strlen(...), ['a', 'bb', 'ccc']);
class Greeter
{
public function greet(string $name): string
{
return "Hello, {$name}!";
}
}
$greeter = new Greeter();
$greetCallable = $greeter->greet(...);
echo $greetCallable('Sam'), "\n"; // Hello, Sam!
Same behavior, but now strlen(...) and $greeter->greet(...) are real references your tooling understands: go-to-definition works, static analysis can check the signature, and a rename of greet() gets caught immediately rather than failing silently at runtime. It reads better too: $greeter->greet(...) says “the greet method, as a value,” which is exactly what’s happening, without a string that happens to be a method name.
Closure::fromCallable()
Sometimes you’re handed something callable (a string, an array pair) from outside your control (a configuration value, perhaps) and want it as a real Closure object so you can call methods like bindTo() on it. Closure::fromCallable() converts any of PHP’s callable shapes into one:
<?php
$callableFromConfig = 'strtoupper';
$closure = Closure::fromCallable($callableFromConfig);
echo $closure('hello'), "\n"; // HELLO
In new code, first-class callable syntax replaces most of the reasons you’d reach for this directly, but you’ll still see Closure::fromCallable() in library code that has to accept a callable in any of its traditional forms and normalize it.
Static closures
By default, a closure defined inside a method silently captures $this, letting it call back into the surrounding object. Occasionally you want the opposite guarantee: a closure that cannot touch the object it was defined in, because it’s going to be handed off somewhere else and you want it to stay self-contained. Mark it static:
<?php
class Report
{
private string $secret = 'internal data';
public function makeFormatter(): Closure
{
return static function (string $line): string {
return strtoupper($line);
};
}
}
$formatter = (new Report())->makeFormatter();
echo $formatter('quarterly summary'), "\n"; // QUARTERLY SUMMARY
A static function closure behaves exactly like an ordinary one except that $this is unavailable inside it: trying to use it is a compile-time error, not a runtime surprise. It’s a small guarantee, but a meaningful one: it tells the reader, and PHP itself, that this closure is genuinely standalone, with no hidden dependency on the object that created it.
Attributes
For years, PHP developers who wanted to attach metadata to a class or method (“this method is a test,” “this property maps to a database column,” “this route handles GET /users”) had exactly one tool: a specially formatted comment, a docblock, that some framework would parse at runtime with a regular expression. It worked, but it was always a little uneasy: the metadata lived in a comment, which the language itself didn’t understand or check, and a typo in it failed silently.
PHP 8 replaced that convention with a real language feature: attributes, written as #[SomethingLikeThis] directly above the thing they describe.
Defining and attaching an attribute
An attribute is just a class, marked with PHP’s own #[Attribute] attribute so PHP knows it’s meant to be used this way:
<?php
#[Attribute]
class Route
{
public function __construct(
public readonly string $method,
public readonly string $path,
) {
}
}
Once defined, attach it to a method with the #[...] syntax:
<?php
class UserController
{
#[Route(method: 'GET', path: '/users')]
public function index(): string
{
return 'List of users';
}
#[Route(method: 'POST', path: '/users')]
public function store(): string
{
return 'User created';
}
}
At this point, nothing runs differently: #[Route(...)] doesn’t call anything on its own. It’s inert metadata, attached to the method, waiting for something to go looking for it.
Reading attributes back with Reflection
That “something” is Reflection, from earlier in this chapter. ReflectionMethod (and ReflectionClass, ReflectionProperty) can list the attributes attached to whatever they’re reflecting, and construct the actual attribute object on demand:
<?php
$reflection = new ReflectionClass(UserController::class);
foreach ($reflection->getMethods() as $method) {
foreach ($method->getAttributes(Route::class) as $attribute) {
$route = $attribute->newInstance();
echo "{$route->method} {$route->path} -> {$method->getName()}()\n";
}
}
$ php routes.php
GET /users -> index()
POST /users -> store()
getAttributes(Route::class) finds every Route attribute on a method, and newInstance() actually constructs it: running the constructor, with the arguments you wrote in #[Route(...)], and handing you back a real Route object with method and path properties. This is genuinely how simple routing systems are built: scan a controller’s methods, read off their Route attributes, and build a routing table from what you find, all without a separate configuration file to keep in sync.
Where you’ve already seen this
If you’ve read Chapter 12, this pattern should look familiar: PHPUnit’s #[Test] attribute marks a method as a test case the same way #[Route] marks one as a handler here: a plain class, read back through Reflection, driving real behavior. Frameworks lean on attributes constantly now: Symfony uses them for routes and dependency injection configuration, Doctrine uses them to map properties to database columns, and PHPUnit uses them for test metadata generally, not just marking a method as a test. You won’t necessarily write many custom attributes of your own day to day, but you’ll read #[...] above methods and classes constantly in modern PHP code, and now you know exactly what’s happening when you do: a plain object, waiting to be read back through Reflection.
Final Project: Building a Simple Web Application
Time to put it all together. Over the last eighteen chapters you’ve picked up classes and constructor promotion, namespaces and Composer, arrays and collections, exceptions, interfaces: a real working vocabulary of modern PHP. This chapter’s project is where those pieces stop being separate lessons and become one small, coherent thing: a tiny web application, built from nothing but PHP itself.
There’s no framework here, deliberately. Not because frameworks are bad (you’ll likely use Laravel or Symfony professionally, and you should) but because using one before you’ve built something without it means taking its conveniences on faith. A router, a controller, a view: these are just words for patterns that fall naturally out of solving the same small problems every web application faces. Build them yourself once, at this small scale, and everything a framework does later will read as “oh, that’s the thing I already understand,” rather than as magic.
We’ll start with the simplest possible router: a single file, PHP’s own built-in development server, and a few if statements deciding what to send back. Then we’ll grow that into something shaped like MVC, with real controller classes and PHP’s original superpower, templating, put to proper use. Finally, we’ll look at how a request’s lifecycle actually ends, and use that moment to run cleanup code reliably, which ties back directly to the request model from Chapter 18.
By the end, you’ll have a working application in well under two hundred lines of code, and a clear-eyed sense of what’s actually happening underneath the frameworks you’ll reach for next.
A Single-File Router with PHP’s Built-in Server
Every web framework, no matter how large, is built on the same basic question: a request comes in for some URL: how does that turn into which piece of code runs? The mechanism that answers that question is called a router. Before reaching for a framework’s version, it’s worth building the smallest one that could possibly work, so you can see exactly what it’s doing.
PHP’s built-in development server
PHP ships with a small web server built into the CLI binary itself: no Apache, no nginx, nothing to install. It’s not meant for production, but it’s genuinely useful for development and, here, for learning:
$ php -S localhost:8000 router.php
[Thu Aug 20 10:00:00 2026] PHP 8.3.0 Development Server (http://localhost:8000) started
That command starts a server on port 8000 and routes every incoming request through router.php. Nothing about matching URLs to files happens automatically: your script decides, for every single request, what to do with it. That’s exactly what we want: total visibility into the mechanism.
The router itself
Create router.php:
<?php
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$routes = [
'/' => function (): string {
return "Welcome to the home page.\n";
},
'/about' => function (): string {
return "This is a tiny PHP application, built without a framework.\n";
},
];
header('Content-Type: text/plain');
if (isset($routes[$uri])) {
echo $routes[$uri]();
} else {
http_response_code(404);
echo "404 Not Found: {$uri}\n";
}
$_SERVER['REQUEST_URI'] is where PHP puts the path the browser actually asked for: /about, /, whatever was typed or clicked. parse_url(..., PHP_URL_PATH) strips off any query string (?foo=bar), so /about?ref=email and /about both resolve to the same route. From there, $routes is just an associative array mapping a path to a closure that produces the response: look up the URI, and if it’s a key we recognize, call the matching closure and echo whatever it returns. If it isn’t, respond with a 404, the same way a real server would.
Try it:
$ curl http://localhost:8000/
Welcome to the home page.
$ curl http://localhost:8000/about
This is a tiny PHP application, built without a framework.
$ curl http://localhost:8000/nonexistent
404 Not Found: /nonexistent
What this is (and isn’t) doing
This router has no path parameters (/users/{id}), no HTTP-method awareness (GET versus POST at the same path), and no middleware. Real routers add all of that, but structurally, they’re doing exactly what’s happening here: inspecting something about the incoming request, and dispatching to a piece of code based on it. You’ve just seen the entire mechanism laid bare, in about fifteen lines.
The next section grows this router into something shaped more like a real application, replacing these inline closures with proper controller classes and adding a view layer for the HTML they’ll eventually need to produce.
Structuring a Small MVC-Style App
The previous section’s router works, but it doesn’t scale past a handful of routes: every route’s logic lives inline, as a closure, tangled up with the routing itself. Real applications separate those concerns: the classic split is Model, View, Controller, usually shortened to MVC. We won’t build a full framework’s worth of it, but the shape is worth having: a controller decides what should happen for a given request, and a view decides how the result gets turned into HTML. Two or three routes are enough to see the pattern clearly.
Controllers
A controller, at this scale, is nothing more exotic than a class whose methods each handle one route and return a response body as a string:
<?php
class HomeController
{
public function index(): string
{
return render('home', ['title' => 'Welcome']);
}
}
class AboutController
{
public function show(): string
{
return render('about', [
'title' => 'About',
'description' => 'A tiny PHP application, built without a framework.',
]);
}
}
Nothing here talks to $_SERVER or knows what URI it was reached by: that’s the router’s job, not the controller’s. Each method’s only responsibility is producing a response, which keeps it easy to reason about, and easy to test in isolation.
Views: PHP’s original superpower
render() is where the view layer lives, and it’s worth pointing out something PHP has been good at from the very start: PHP is a templating language underneath the programming language; that was literally its original purpose, before it grew everything else. A “view” here is just an ordinary PHP file with HTML in it and small islands of PHP for the dynamic parts, the same <?php ... ?>-in-HTML style you’d have used to build the very first pages this book showed you.
Create views/home.php. Unlike the other code samples in this book, this one is an HTML file with small islands of PHP in it, not a PHP file in its own right, so it doesn’t open with <?php:
<!DOCTYPE html>
<html>
<head><title><?= htmlspecialchars($title) ?></title></head>
<body>
<h1><?= htmlspecialchars($title) ?></h1>
<p>This page was rendered from views/home.php.</p>
</body>
</html>
And a small helper that includes a view file with data made available to it:
<?php
function render(string $view, array $data = []): string
{
extract($data);
ob_start();
include __DIR__ . "/views/{$view}.php";
return ob_get_clean();
}
extract() turns each key of $data into a local variable: 'title' => 'Welcome' becomes a variable $title, visible inside the included file. ob_start() and ob_get_clean() are output buffering: instead of letting the included file’s HTML print straight to the browser, we capture it as a string and hand it back, so the controller can return it like any other value. Notice <?= $title ?> inside the view: the <?= ?> tag is shorthand for <?php echo ?>, and it’s routed through htmlspecialchars() here specifically to avoid rendering user-influenced data as raw HTML.
Wiring the router to controllers
Update router.php to dispatch to controller methods instead of inline closures:
<?php
require __DIR__ . '/render.php';
require __DIR__ . '/controllers.php';
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$routes = [
'/' => [HomeController::class, 'index'],
'/about' => [AboutController::class, 'show'],
];
if (isset($routes[$uri])) {
[$class, $method] = $routes[$uri];
echo (new $class())->{$method}();
} else {
http_response_code(404);
echo "404 Not Found: {$uri}";
}
The $routes array now maps each path to a [class, method] pair instead of a closure, destructured right there with [$class, $method] = $routes[$uri], the syntax from Chapter 19. new $class() instantiates the controller, and ->{$method}() calls the matching method on it. It’s a small amount of machinery, but it’s genuinely the same idea every framework’s router is built on: look at the request, find a class and method responsible for it, call it, return what it gives you.
Handling Shutdown and Cleanup
Every PHP script, including the little application you’ve just built, ends somehow: normally, by running its last line, or abnormally, by a fatal error nobody planned for. Either way, there’s often cleanup you want to guarantee happens: closing a file handle, logging that the request finished, flushing something to a database. try/finally (from Chapter 9) handles the ordinary cases, but it can’t save you from a genuine fatal error: the kind that stops execution dead, with no exception to catch. For that, PHP gives you a hook into the very end of the script’s life.
register_shutdown_function()
<?php
register_shutdown_function(function (): void {
echo "Cleaning up before the script ends.\n";
});
echo "Doing regular work.\n";
// Simulate something going badly wrong.
strlen(); // fatal error: too few arguments
$ php shutdown_demo.php
Doing regular work.
Cleaning up before the script ends.
Fatal error: Uncaught ArgumentCountError: strlen() expects exactly 1 argument, 0 given...
Notice the order: PHP prints the cleanup message before the fatal error output, because the shutdown function runs at the true end of the request (after a normal return, after an uncaught exception, and after most fatal errors) regardless of how the script got there. You register a callback once, near the top of your application (or, more realistically, inside a framework’s bootstrap code), and PHP guarantees it runs on the way out. It’s the closest thing PHP has to “no matter what happens, run this last.”
Where a script’s life actually ends
This ties directly back to Chapter 18’s shared-nothing request model. In the traditional PHP lifecycle, a script’s “end” isn’t a vague concept: it’s the moment the response has been sent and the process (or thread) handling this one request is about to be recycled or torn down for the next request entirely. Everything that script allocated (variables, objects, open file handles PHP itself manages) is cleaned up as part of that teardown, shutdown functions included. There’s no lingering process to leak memory into over time the way a long-running Node.js server can; each request gets a clean slate, and each request’s mess, cleaned up or not, dies with it.
That’s also why register_shutdown_function() matters more in PHP than the phrase “runs at the end” might suggest at first. It’s not a background job, and it’s not deferred to some later point the way a queued job from Chapter 18 is: it runs synchronously, inline, before this exact request’s story is finished, which makes it the right place for things like “log that this request completed” or “release a lock this request was holding,” and the wrong place for anything that should happen independently of this request at all.
Where you’ve landed
Look back at what this final project actually used: a router built from an array and a handful of if statements, controllers that are just plain classes with methods, a view layer that leans on the same PHP-in-HTML style you saw on page one, and now a shutdown hook that closes the loop on a request’s lifecycle. None of it required a framework. All of it is, in miniature, what a framework provides at scale.
That’s a fitting place to leave you. You started this book with echo "Hello, world!\n"; (about as small as a program can be) and you’ve ended it wiring together classes, namespaces, interfaces, error handling, and a request lifecycle into something that actually serves web pages. The syntax in between was never really the point; the point was building the judgment to reach for the right piece of it at the right moment. That judgment is the part no book can finish for you: it only comes from writing more PHP than you’ve written so far. Go write some.
Where to Go from There
You’ve covered a full arc: syntax and control flow, error handling, collections, classes and interfaces, testing, debugging, and a small web application built from first principles, router and all. That’s real fluency in the language. It’s not, by itself, everything professional PHP work involves. Most of what’s left is less about PHP-the-language and more about PHP-in-context: the tools that carry code from your machine into production, the practices that keep a team’s use of it sane, the wider technical world it talks to, and the people already doing all of it.
This chapter is a map, not a tutorial, in the same spirit Chapter 18 took toward concurrency: enough to recognize each destination by name, know roughly what it’s for, and know what to search for once you actually need it. None of it is required to write good PHP. All of it becomes relevant as the software you write gets bigger, longer-lived, or worked on by more than one person, which is to say, eventually, most of it.
Five destinations, each covered just enough:
- Frameworks and tooling: the ready-made version of what Chapter 21 built by hand, and the toolchain around it.
- Architecture and process: organizing code, and the team practices that keep changes to it safe.
- Security and performance: hardening what Chapter 10 started, and keeping it fast once real traffic shows up.
- Beyond PHP: other languages, other technologies, and the engine PHP itself runs on.
- The community: the people who built everything above, and how to find them.
Read whichever section is relevant to what you’re about to do next. None of them assume you’ve read the others first.
Frameworks and the Tooling Around Them
Frameworks
Chapter 21 built a router, controllers, and a view layer by hand, on purpose: so none of it would feel like magic. A framework gives you that same shape, already built, tested by thousands of other projects, with an ecosystem of packages assembled around it. Reaching for one isn’t admitting defeat; it’s skipping work that’s already been done well.
Two dominate the PHP world, and they make different trade-offs:
- Laravel: batteries-included. An ORM (Eloquent), a templating engine (Blade), a command-line tool (Artisan), queues, authentication scaffolding, and more, all designed to work together out of the box. The most common entry point for a new PHP project today.
- Symfony: components-first. Its pieces (routing, dependency injection, the HTTP abstraction) are usable individually, and it favors explicitness over convention. Often the choice for larger, longer-lived codebases, and parts of it quietly power other projects, Laravel included.
Smaller frameworks (Slim, Mezzio) exist for cases where a full framework is more than a project needs, an API with no views, say. Pick based on what the project and team actually need, not which one is loudest online; because you’ve already built the pieces by hand, none of the big ones should feel opaque.
Tooling
Appendix D covered the tools you run while writing PHP: Composer, PHPUnit, static analysis, a debugger. The next layer of tooling is about what happens after you write the code: carrying it safely from your machine into production, and keeping it healthy once it’s there.
- Continuous integration (GitHub Actions, GitLab CI): running your test suite, PHPStan, and your style checker automatically on every push, so a broken change is caught before a human has to notice it.
- Containers (Docker): packaging PHP, its extensions, and its dependencies into something that runs identically on your laptop, in CI, and in production, ending the “works on my machine” conversation.
- Deployment tooling (Deployer, or managed platforms like Laravel Forge and Platform.sh): automating “get the new code onto the server correctly,” which by hand involves more steps than it looks like it should.
- Rector: automated, mechanical refactoring, including upgrading a codebase across PHP versions in bulk rather than by hand, relevant the moment Appendix E’s backward-compatibility concerns stop being theoretical.
- Infection: mutation testing. It goes one step past what Chapter 12 covered by deliberately introducing small bugs into your code and checking whether your test suite actually notices, which is a sharper question than “do the tests pass.”
None of these are PHP-specific ideas. What’s PHP-specific is how well they fit around it: PHP’s ecosystem has mature, boring, well-documented tooling for all of the above, which is a genuine advantage over flashier ecosystems with thinner tooling underneath them.
Architecture and the Development Process
Architecture
Once a program grows past a handful of files, “where does this piece of code live, and why there” stops being obvious and becomes its own discipline. You’ve already practiced the smallest version of this instinct: the Strategy pattern in Chapter 17 pulled varying behavior out behind an interface so a class using it didn’t need to know which version it received. Architecture is that same instinct, applied to an entire codebase instead of one class.
A few names worth recognizing:
- Layered architecture: separating concerns the way Chapter 21’s router, controllers, and views did, formalized into explicit layers (presentation, domain logic, persistence) with rules about which layer is allowed to depend on which.
- Domain-Driven Design (DDD): naming classes and methods after the concepts the business actually uses, not after the framework’s folder structure, so the code reads like the problem it solves.
- Hexagonal architecture (ports and adapters): keeping your core logic ignorant of the database and framework at its edges, so that logic can be tested and reasoned about without either one in the room.
- Monolith versus microservices: a well-organized monolith stays the right choice for longer than internet conventional wisdom suggests. Splitting a system into services solves organizational problems (many teams shipping independently), not technical ones, and introduces real new problems of its own, coordination across a network instead of within one process, which is Chapter 18’s shared-nothing model repeated at a much larger scale.
None of these are rules to apply everywhere. They’re vocabulary: names for shapes of code organization, so that when a codebase’s structure starts causing pain, you have a name for what to look up.
The software development lifecycle
Architecture organizes code. The rest of the practices around a project organize the people changing that code, and how a change gets from an idea to something running safely in production:
- Branching and code review: a shared workflow for proposing a change and having someone else look at it before it merges, catching problems a test suite doesn’t.
- Versioning and releases: semantic versioning, the same MAJOR.MINOR.PATCH scheme Appendix E used to describe PHP’s own backward-compatibility guarantees, applies equally to any package you publish through Chapter 16.
- Environments: keeping local, staging, and production meaningfully similar, built on the environment variables from Chapter 14 rather than hardcoded differences.
- Issue tracking and changelogs: a record of what changed and why, separate from the commit history, that a teammate (or you, in six months) can actually read.
None of this is PHP-specific. What’s worth flagging is that PHP’s fast edit-and-reload loop, no compile step, no build wait, makes it unusually easy to skip this discipline early on and feel nothing go wrong, right up until a project has enough history and enough contributors that skipping it finally costs something.
Security and Performance
Security
Chapter 10 covered XSS and SQL injection properly, and named CSRF without defending against it. That’s the beginning of web application security, not the whole of it. A few directions worth knowing exist:
- Authentication and authorization: verifying who’s making a request and what they’re allowed to do.
password_hash()andpassword_verify()are PHP’s built-in, correctly-salted way to store passwords; sessions track a logged-in user across requests despite Chapter 18’s shared-nothing model; OAuth handles “log in with an account from somewhere else.” - Dependency security:
composer auditchecks your installed packages against a database of known vulnerabilities, and Roave Security Advisories can block installing a package version with a known issue in the first place. A codebase is only as secure as the packages Chapter 16 pulled in. - The OWASP Top 10: a standard, regularly updated checklist of the most common web application vulnerabilities, XSS and SQL injection among them. Worth reading once as a map of what to defend against beyond what this book covered.
- Secrets management: never committing credentials to a repository. Environment variables, the same mechanism from Chapter 14, are the floor; dedicated secrets stores (Vault, a cloud provider’s secrets manager) are the ceiling for anything handling real user data.
Performance and observability
- Opcache: PHP compiles source code to bytecode on every single request by default. Opcache caches that compiled bytecode between requests, and turning it on in production isn’t optional so much as assumed.
- Caching layers (Redis, Memcached): a place to store data that’s expensive to recompute or refetch, given that Chapter 18’s shared-nothing model means nothing survives between requests unless you deliberately put it somewhere, the same reason Chapter 10 reached for a database at all.
- Profiling in production: Xdebug’s profiler, from Chapter 13, is a development-time tool, too slow to leave running under real traffic. Production observability leans on lighter tools instead: Blackfire, or general application performance monitoring products like Datadog and New Relic.
- Logging, metrics, and tracing: knowing what a request actually did after the fact matters more in PHP than in a long-running server process, precisely because each request’s local state disappears the moment it ends. Structured logs, request-level metrics, and distributed tracing are how you reconstruct what happened once “just add a
var_dump()and rerun it” isn’t an option anymore.
Both directions share a theme: the guestbook from Chapter 10 and the final project from Chapter 21 were built to teach the underlying model correctly. Neither was built to survive a hostile internet or serious traffic, and that’s fine; that’s what this section is for.
Beyond PHP: Other Languages, Other Technologies, and the Engine Itself
Talking to other languages
- FFI (Foreign Function Interface, since PHP 7.4): calling directly into a compiled C library from PHP, without writing a full extension. Narrow, useful when it applies, worth knowing exists.
- APIs (HTTP, gRPC): the far more common way real systems mix languages, not by linking them together in one process, but by having each side expose a language-neutral interface that any language can call. A PHP backend and a service written in Go or Rust talk to each other this way constantly, neither one aware what the other is written in.
proc_open(), from Chapter 18: the low-effort version, shelling out to a program written in something else entirely and reading back what it prints.
Talking to other technologies
- Databases beyond SQLite: Chapter 10 used SQLite because it needed no separate server. MySQL, MariaDB, and PostgreSQL are what most production PHP talks to instead, through the same PDO interface, a different DSN, and each with its own SQL dialect quirks worth knowing about.
- Message queues: Chapter 18’s queue example, scaled up with dedicated software like RabbitMQ or Amazon SQS, for background work that needs to survive a crash or fan out across multiple workers reliably.
- Search engines (Elasticsearch, Meilisearch): for the moment a
LIKE '%...%'query stops being good enough, full-text and faceted search need infrastructure built for exactly that. - Cloud services: object storage (S3 and its many compatible alternatives), managed databases, managed queues, largely the same ideas as above, run and scaled by someone else.
Extending the engine itself
- PHP extensions: the mechanism behind the PDO drivers and Xdebug you’ve already used, written in C against the Zend Engine’s own API, and installed through PECL.
- Zephir: a higher-level language that compiles down into a real PHP extension, for teams who want extension-level performance without writing raw C by hand.
- Worth knowing this layer exists, rarely worth reaching for. Almost everything an application needs is achievable in ordinary, userland PHP; writing an extension is a decision for when PHP itself is the bottleneck, not the application sitting on top of it, and that’s a rare place to end up.
The PHP Community
Everything in this chapter, and most of this book, exists because of work other people did in public: extensions, frameworks, standards, RFCs. Finding that community is less a “next step” than a shortcut through all the others.
- User groups: local, often monthly meetups, typically listed through sites like php.ug. A low-effort way to meet other PHP developers in person and hear what problems they’re actually solving.
- Conferences: PHP UK, phpDay, SymfonyCon, Laracon, and many more worldwide. Talks matter less than the hallway conversations between them; either way, a good reminder that the language has a genuinely active present, not just the history the foreword addressed head-on.
- PHP-FIG and the PSRs: the Framework Interop Group, responsible for the PSR standards this book has leaned on silently throughout, PSR-4 autoloading from Chapter 7, PSR-12 style from Appendix D. Its proposals and meetings are public.
- The RFC process: Appendix G covered how PHP itself changes. The
internals@lists.php.netdiscussions behind every RFC are open to read, and eventually, open to join. - Contributing: to PHP’s own source or documentation, or to any of the open source packages this community’s projects rest on, a great many of which live on Packagist, from Chapter 16. Fixing a typo in a documentation page is a small, legitimate, genuinely welcomed first contribution, and a good way to find out how a much larger codebase than any in this book is actually organized.
The fastest way to grow past this book is to talk to people who already have. Every destination in this chapter has a community of people standing at it, happy to explain what they found.
Appendix
The chapters before this were meant to be read. This part is meant to be looked things up in. Nobody memorizes the full list of PHP’s reserved keywords, and nobody should have to: that’s what an appendix is for.
Eight sections follow. A lists the reserved keywords you can’t use as identifiers. B is a reference table of operators and symbols. C recaps the SPL interfaces and magic methods scattered through earlier chapters, gathered in one place. D is a tour of the tools worth installing once you’re past the basics. E covers version support and backward compatibility. F and G are shorter still: translation status, and a look at how PHP itself gets decided. H links every feature this book covers to its entry in the online PHP Dictionary.
Skim it now if you like, but you’ll get more out of it the next time you’re mid-project and can’t remember whether it’s ??= or ?=.
A - Keywords
The following words are reserved by PHP. You can’t use any of them as the name of a variable, function, class, constant, or namespace: the parser has already claimed them for something else.
Control flow
if · else · elseif · endif · while · endwhile · do · for · endfor · foreach · endforeach · as · switch · endswitch · case · default · match · break · continue · goto · return · yield
Class-related
class · interface · trait · enum · extends · implements · new · clone · instanceof · abstract · final · public · protected · private · readonly · static · const · var · function · fn · use
Error handling
try · catch · finally · throw
Namespaces and includes
namespace · use · require · require_once · include · include_once
Other
echo · print · declare · enddeclare · global · list · array · isset · unset · empty · exit · die · and · or · xor · not · int · float · bool · string · null · true · false · void · mixed · never · self · parent
That last group of type names (int, string, null, true, false, and the rest) deserves a note: they only became reserved gradually, as PHP added them as proper type declarations. Older code sometimes used String or Int as class names, back when that was still legal. It isn’t anymore.
use shows up in two groups above because it does two unrelated jobs: importing names from a namespace (Chapter 7) and capturing variables into a closure (Chapter 15). Same word, same reservation, different context.
None of these can be repurposed, no matter how well the name would otherwise fit your code. Try to name a variable $class: that one’s fine, actually, keywords only block bare identifiers, not variable names after the $. Try to name a function list() or a class Match, and PHP will stop you at parse time, not at runtime. Better there than in production.
B - Operators and Symbols
A reference table, grouped by what the operators actually do rather than alphabetically. Alphabetical order is great for dictionaries and terrible for remembering anything.
Arithmetic
| Operator | Meaning |
|---|---|
+ | Addition |
- | Subtraction |
* | Multiplication |
/ | Division |
% | Modulo (remainder) |
** | Exponentiation |
Assignment
| Operator | Meaning |
|---|---|
= | Assign |
+= -= *= /= | Arithmetic, then assign |
.= | Concatenate, then assign |
%= **= | Modulo / exponentiate, then assign |
Each compound assignment operator is shorthand: $x += 1 is exactly $x = $x + 1, just shorter and, once you’re used to it, easier to read at a glance.
Comparison
| Operator | Meaning |
|---|---|
== | Equal, after type juggling |
=== | Identical: same type and value, no juggling |
!= <> | Not equal |
!== | Not identical |
< > <= >= | Less than, greater than, and their “or equal” variants |
<=> | Spaceship |
The spaceship operator (<=>) compares two values and returns -1, 0, or 1, meaning less than, equal, or greater than, which is exactly the three-way answer sorting callbacks expect:
<?php
$numbers = [5, 3, 8, 1];
usort($numbers, fn($a, $b) => $a <=> $b);
Before it existed, that comparison took three lines of if. Now it’s one operator doing what it says.
Prefer === over == by default, for the reasons covered in Chapter 3.
Logical
| Operator | Meaning |
|---|---|
&& | And |
|| | Or |
! | Not |
and or xor | Word forms of and/or/exclusive-or |
and/or do the same job as &&/||, but at much lower precedence, low enough to lose to =. This compiles, and does not do what it looks like it does:
<?php
$result = false or true;
var_dump($result); // bool(false)
= binds tighter than or, so that line is actually ($result = false) or true: $result gets false, and the or true is discarded as an unused expression. Swap in || and it works as expected. Stick to && and ||; leave and/or/xor alone unless you have specifically memorized their precedence table, which is not a thing worth memorizing.
String
| Operator | Meaning |
|---|---|
. | Concatenation |
.= | Concatenate and assign |
Array
| Operator | Meaning |
|---|---|
+ | Union: keys from the left array win on conflict |
... | Spread: unpacks one array’s elements into another, or into a function call |
Array + is not array merging; see Chapter 8 for the difference between + and array_merge(), which handle duplicate keys in opposite ways.
Null-related
| Operator | Meaning |
|---|---|
?? | Null coalescing: right side, only if left side is null or unset |
??= | Null coalescing assignment |
?-> | Nullsafe method/property access |
<?php
$name = $user->name ?? 'Anonymous'; // fall back if null
$config['retries'] ??= 3; // set only if not already set
$city = $user?->address?->city; // null, not a fatal error, if either is null
All three are covered properly in Chapter 6.
Other symbols
| Symbol | Meaning |
|---|---|
$ | Marks a variable name |
-> | Access a property or method on an object instance |
:: | Access a static property, static method, class constant, or parent from within a class |
#[...] | Attribute: structured metadata attached to a class, method, or property |
Attributes are the newest of the four, and get a full treatment in Chapter 20.
C - Built-in Interfaces and Magic Methods
Two reference tables: the SPL interfaces that let your objects plug into PHP’s built-in language features, and the magic methods that let your objects hook into behavior PHP would otherwise handle for you.
Built-in interfaces
| Interface | Implementing it gets you |
|---|---|
Countable | Your objects work with count() |
ArrayAccess | Your objects support $obj[$key] syntax: read, write, isset, and unset |
Iterator | Your objects work directly in foreach, with full control over the iteration |
IteratorAggregate | Your objects work in foreach by delegating to another iterator, usually a Generator |
Stringable | Your objects can be used anywhere a string is expected |
Stringable is the odd one out: added in PHP 8, and you rarely need to implement it explicitly; any class that defines __toString() is automatically treated as implementing it. It exists mostly so type declarations can say “anything printable,” rather than listing every class that happens to have a __toString() method.
Full examples of all five, including what Iterator demands of you that IteratorAggregate doesn’t, are in Chapter 20.
Magic methods
| Method | Called when |
|---|---|
__construct | An object is created |
__destruct | An object is about to be destroyed |
__get | Reading an inaccessible or undefined property |
__set | Writing to an inaccessible or undefined property |
__call | Calling an inaccessible or undefined instance method |
__callStatic | Calling an inaccessible or undefined static method |
__toString | The object is used in a string context |
__invoke | The object is called as if it were a function |
__clone | The object is duplicated with clone |
“Magic” is PHP’s word for methods the language calls for you, by naming convention, rather than you calling directly. Useful for building things like lazy-loaded properties or fluent proxies, easy to overuse into code nobody can trace by reading it. Full treatment, with the tradeoffs, in Chapter 17.
D - Useful Development Tools
This book has already covered two of these properly. The rest are what to go install next: not exhaustive documentation of any one of them, just enough to know what each one is for and why working PHP developers bother.
Composer
Covered from Chapter 7 onward, and again in depth in Chapter 16. Dependency management and autoloading. You will not write PHP professionally without it, and by this point in the book you already haven’t.
PHPUnit
Covered in Chapter 12. The standard testing framework. If a PHP project has tests, they are very likely PHPUnit tests.
PHPStan and Psalm
Static analysis tools: they read your code without running it and tell you where it’s wrong, or at least where it’s suspicious. Both understand PHP’s type system more strictly than PHP itself does at runtime; they’ll catch a call to a method that doesn’t exist, a null passed where the type says it can’t be, a return type that quietly stopped matching what the function returns. This is exactly the territory Chapter 11 touches on with docblock-based generics: PHP’s own type system can’t express “an array of User objects,” but a docblock annotation combined with PHPStan or Psalm reading it can check that promise for you.
Neither ships with PHP. Both install via Composer, both run in CI, and both are worth adding to a project on day one rather than after the bugs they’d have caught have already shipped.
$ composer require --dev phpstan/phpstan
$ vendor/bin/phpstan analyse src
PHP-CS-Fixer and PHP_CodeSniffer
Code style enforcement: not “is this correct” but “is this formatted the way the team agreed to format it.” Both can check a codebase against PSR-12 (PHP’s standard style guide) and, more usefully, both can fix violations automatically rather than just listing them.
$ vendor/bin/php-cs-fixer fix src
Pick one, wire it into your editor or a pre-commit hook, and stop having style debates in code review; let the tool have that argument instead.
Xdebug
A step debugger and profiler for PHP. Instead of scattering var_dump() calls through your code and rerunning it, Xdebug lets you pause execution at a breakpoint, inspect every variable in scope, and step through line by line, from your editor, in real time. It also profiles: showing you exactly where a slow request spent its time. Covered properly, installation and all, in Chapter 13.
Editors and IDEs
PHP doesn’t require any particular editor, but two are worth knowing about:
PhpStorm: a dedicated PHP IDE with deep, built-in understanding of the language: refactoring, navigation, and inline static analysis that rivals PHPStan without leaving the editor. Commercial, free for students and open source maintainers.
VS Code, with the PHP extensions (Intelephense or the official PHP extension pack): free, general-purpose, and perfectly capable once configured. What most PHP developers who don’t use PhpStorm reach for.
Either is a fine choice. What matters is picking one and learning it properly, rather than fighting a half-configured editor on top of learning the language.
E - PHP Versions and Backward Compatibility
Release cadence
PHP ships a new minor version roughly once a year. Each version gets about two years of active support (new features, bug fixes, security patches) followed by roughly one more year of security-only support before it reaches end of life. After that, running it in production is running unpatched software, full stop.
Check what you’re running:
$ php -v
PHP 8.3.0 (cli) (built: ...)
Or from inside a running script:
<?php
echo phpversion(); // "8.3.0"
A short history
PHP 5 to PHP 7 was a genuinely huge leap: a rewritten engine, roughly double the performance, and the introduction of scalar type declarations. PHP 7 to PHP 8 was smaller in raw performance terms but denser in language features: the JIT compiler, union types, enums, attributes, named arguments, the nullsafe operator, constructor promotion, match. Most of what this book leans on (enums in Chapter 6, attributes in Chapter 20, and constructor promotion in Chapter 5) didn’t exist before PHP 8. This book targets PHP 8.1 and later for exactly that reason.
Pin a minimum version
Tell Composer, and anyone installing your package, what it actually needs:
{
"require": {
"php": ">=8.1"
}
}
This isn’t a formality. Without it, Composer will happily let your package install on a PHP version that doesn’t have the features you’re using, and the failure will happen at runtime instead of install time, which is a much worse place to discover it.
Don’t fear upgrading
PHP takes backward compatibility within a major version seriously. Code written for PHP 8.0 runs, largely unmodified, on PHP 8.3. Deprecation notices generally show up one or two versions before something is actually removed, giving you real warning rather than a surprise. Upgrading is rarely the ordeal older reputations about PHP suggest: the bigger risk, in practice, is staying on an unsupported version and quietly losing security patches.
F - Translations of the Book
This edition is written in English. There are no other translations yet.
If that changes, they’ll be linked from this page. If you’re interested in producing one, that’s a conversation worth having, but there’s nothing to link to today, and this page won’t pretend otherwise.
G - How PHP Is Made (the RFC Process)
At some point, reading through enums, match, attributes, and readonly properties, it’s worth asking: who decided PHP should work this way? The answer is public, documented, and more interesting than “a company decided.”
PHP’s language evolution happens through RFCs (Request for Comments), proposed and discussed on the internals@lists.php.net mailing list. Anyone can write one. The process, roughly:
- Someone drafts an RFC describing a proposed change (new syntax, a new function, a change to existing behavior) with motivation and, usually, a working implementation to point at.
- It’s posted to the mailing list and discussed publicly, often for weeks, sometimes for months. Discussion is not a formality; RFCs get substantially reworked, or abandoned, based on it.
- Once discussion settles, it goes to a vote among PHP’s voting members: established core contributors, not the general public.
- Most language-level RFCs need a two-thirds majority to pass. Some narrower changes need only a simple majority; the RFC process page specifies which threshold applies to which category of change.
Every finished RFC lives at wiki.php.net/rfc, vote tally and all. Enums, match, attributes, readonly properties: everything this book has leaned on that didn’t exist before PHP 8 went through exactly this process, usually after real public disagreement about whether it was the right idea.
Worth reading through if you’re curious, and worth remembering the next time a piece of PHP syntax seems arbitrary. Somebody had to argue for it, in public, against people arguing the other way.
H - Covered PHP Features
Every chapter in this book introduces a piece of PHP: a keyword, an operator, a built-in interface, a language mechanism. This appendix pulls them all into one list, each one linked to its entry in the PHP Dictionary, an independent, ever-growing reference of PHP terms, keywords, functions, and jargon. Use it the way you’d use any glossary: when a term from an earlier chapter comes back and you want the short version again, without hunting back through the chapter that first introduced it.
A handful of items below have no dictionary entry yet. They’re listed anyway, plainly, without a link.
Syntax and basics
- Opening tag
<?php: switches the parser from HTML mode into PHP mode. - Short echo tag
<?= ?>: shorthand that combines<?phpwith an immediateecho. echoandprint: output constructs, covered in Chapter 1.- String interpolation: embedding variables directly inside a double-quoted string.
- Comments and docblocks:
//,#,/* */, and the structured/** */form tools read, from Chapter 3.
Types and comparison
- Type juggling: PHP’s automatic conversion between types depending on context.
- Casting: explicit conversion with
(int),(string), and the rest. - Boolean,
gettype(),var_dump(): the scalar type system and how to inspect it, from Chapter 3. declare(strict_types=1): opts a file out of implicit scalar coercion.- Identical operator
===, equal operator==, loose comparison: the two families of comparison and where they diverge. - Spaceship operator
<=>: three-way comparison, returns-1,0, or1. - Union types: a parameter or return type expressed as
int|string. TypeError: thrown when a value doesn’t satisfy a type declaration.- Array: PHP’s one compound type doing double duty as list and map, from Chapter 8.
array_key_exists()andisset(): checking for a key versus checking for a non-null value.
Control flow
if/elseif/elseand conditional structures generally.while,do-while,for,foreach: the loop constructs, from Chapter 3.breakandcontinue: leaving or skipping ahead in a loop, including their optional numeric argument for nested loops.switchandmatch: the two branch-and-compare constructs, one a statement, one an expression, covered together in Chapter 6 and again as pattern syntax in Chapter 19.list()/ array destructuring and destructuring generally: unpacking an array into separate variables in one step, from Chapter 19.
Functions and closures
- Function declarations, return type declarations, and default parameter values.
void: a return type declaring that a function returns nothing meaningful.- Named arguments: calling a function by parameter name instead of position.
- Passing by reference versus passing by value: whether a function can modify the caller’s variable.
- Anonymous functions and closures: functions as values, with variables captured via
use, from Chapter 15. - Arrow functions (
fn): single-expression closures with implicit capture of the outer scope. - First-class callable syntax
foo(...): converting a named function or method reference into a realClosure. - Generators and
yield: functions that produce values lazily, one at a time, from Chapter 15.
Classes and objects
- Class declarations,
new,instanceof,clone: the basic vocabulary of object creation. - Visibility (
public,protected,private): controlling access to properties and methods. - Constructor property promotion and
readonlyproperties: shorthand construction and write-once properties, from Chapter 5. - Typed properties: declaring a property’s type up front.
staticproperties and methods, static variables, and late static binding: the several unrelated jobs thestatickeyword does.- Inheritance,
extends, andparent::: building one class on top of another, from Chapter 17. - Abstract classes and abstract methods: base classes that can’t be instantiated on their own.
- Interfaces and traits: shared contracts versus shared implementation, from Chapter 11.
- Constructor (
__construct), destructor (__destruct), and other magic methods:__toString(),__get()/__set(),__call(), covered in Chapter 17. - Generics, via docblocks: PHP has no native generics, so static analysis tools read the type from a comment instead, discussed in Chapter 11.
Enums
Namespaces and autoloading
- Namespaces and
useimports: organizing and importing names, from Chapter 7. - Autoloading: loading class files on demand instead of with a pile of
requirestatements. - PSR-4: the autoloading standard Composer implements, covered in Chapter 7. Not yet in the dictionary; only mentioned there in passing.
Error handling
Exception,Error,Throwable: PHP’s two parallel throwable hierarchies, from Chapter 9.try/catch/finallyandDivisionByZeroError: catching and handling failure.RuntimeException: one of PHP’s built-in exception subclasses, used as a base in the CLI project starting Chapter 14.- Custom exception classes (
extends Exception): a common idiom, but not yet its own dictionary entry.
Web and databases
- Superglobals,
$_GET,$_POST, and$_SERVER: reading a web request’s data, from Chapter 10. htmlspecialchars()and XSS: escaping output to stop an attacker’s markup from running in someone else’s browser, from Chapter 10.- CSRF: forged cross-site requests, named but not defended against in this book, from Chapter 10.
- PDO,
PDOException, and SQLite3: a consistent, file-based way to talk to a database, from Chapter 10. - Prepared statements and SQL injection: separating a query’s structure from its values to stop an attacker from rewriting the query.
Debugging
var_dump(),print_r(), andvar_export(): printing a value’s structure (and, forvar_dump(), its type) while chasing a bug, from Chapter 13.- Xdebug: a step debugger and profiler, pausing execution at a breakpoint instead of guessing where to print, from Chapter 13.
- Profiling: measuring where a script actually spends its time, one of Xdebug’s other jobs.
Concurrency
pcntl: the extension behind forking and controlling separate OS processes.
Reflection and attributes
- Reflection: inspecting classes, methods, properties, and attributes at runtime, from Chapter 20.
- Magic constants (
__CLASS__,__FUNCTION__,__METHOD__,__LINE__,__FILE__): compile-time constants describing the code’s own location. - Attributes
#[...]: structured metadata attached to code and read back through Reflection, from Chapter 20.
Built-in interfaces
Countable: letscount()work on a custom object.ArrayAccess: enables square-bracket access on a custom object.IteratorandIteratorAggregate: the two ways to make an object work inforeach, from Chapter 20.
Odds and ends
global: pulling a variable in from the global scope.- Copy-on-write: why passing an array by value is cheap until something actually writes to it, from Chapter 4.
- Garbage collection: how PHP reclaims memory from objects nobody references anymore.
assert(): a debug-time sanity check, from Chapter 12.- PHPUnit: the testing framework used throughout the book’s later chapters.
getenv()and$_ENV: reading environment variables, from Chapter 14.fwrite(STDERR, ...): writing to standard error instead of standard output.- Output buffering: capturing generated output into a buffer instead of sending it immediately.
register_shutdown_function(): a callback PHP guarantees to run at the end of a script, from Chapter 21.$thisandSTDIN: both used constantly from Chapter 2 onward, neither has its own dictionary entry yet.random_int(): PHP’s cryptographically secure random integer function, also not yet listed.
The gaps are worth noticing as much as the links. A few things this book leans on hard, $this, STDIN, PSR-4, custom exception classes, don’t have an entry in the dictionary yet. If you find yourself explaining one of them to someone else, that explanation is most of a dictionary entry already.