Throwing Exceptions
Pick the built-in class that describes the failure, and when you rethrow, pass the original as previous so the cause survives.
throw new RuntimeException(string $message = "", int $code = 0, ?Throwable $previous = null);Throwing takes one line, but the class you choose is an interface. The class is what callers write in their catch, so it carries more meaning than the message ever will.
The SPL hierarchy
PHP ships a set of exception classes under Exception, split into two branches.
| Branch | Means | Members |
|---|---|---|
LogicException | the calling code is wrong and a person must change it | InvalidArgumentException, DomainException, LengthException, OutOfRangeException, BadFunctionCallException, BadMethodCallException |
RuntimeException | only knowable while running, and may be worth retrying | OutOfBoundsException, RangeException, OverflowException, UnderflowException, UnexpectedValueException |
A rough guide: a bad argument is InvalidArgumentException; a missing file, a refused payment or a dead connection is a RuntimeException.
Rethrowing without losing the cause
The constructor is __construct(string $message = "", int $code = 0, ?Throwable $previous = null). When you catch a low-level failure and throw something your caller understands, pass the original in as $previous. getPrevious() then walks back to it, and a log can print the whole chain.
throw is an expression
Since PHP 8.0 throw can appear on the right of ?? and ?: and inside an arrow function. $name = $input['name'] ?? throw new InvalidArgumentException('name is missing'); is the same thing as an isset check with a throw in its body.
Example
When to use it
- Reject an out-of-range argument at the top of a method instead of storing it.
- Hide a driver-specific PDOException behind a class your own application defines.
- Keep the original failure attached while rethrowing, so the log shows the real cause.
- Require a form field in one line with ?? and a throw expression.
More examples
The throw expression beside the longhand
The short form keeps a guard on the line that needs the value, which reads well for a list of required fields. Note that ?? treats a present-but-null value as missing, so the two forms above agree only because isset() behaves the same way; array_key_exists() does not. $_POST has no value on the command line, which is why this example is shown rather than run.
<?php
declare(strict_types=1);
// Shorthand, PHP 8.0 and later: throw is an expression, so it can be the
// right-hand side of ??.
$name = $_POST['name'] ?? throw new InvalidArgumentException('name is missing');
// Longhand: exactly the same behaviour.
if (!isset($_POST['name'])) {
throw new InvalidArgumentException('name is missing');
}
$name = $_POST['name'];
// It works wherever an expression is allowed, including arrow functions:
$required = static fn(array $in, string $k): string
=> $in[$k] ?? throw new InvalidArgumentException("{$k} is missing");Wrapping a database failure
Callers of findUserEmail() should not have to know that PDO exists, so the PDOException is wrapped in a class that belongs to this application. The original goes in as $previous, which matters here: the PDOException holds the driver's own message, and that is the part that tells you whether the server is down or the column is misspelled. The query is a prepared statement with a named placeholder, so the id can never be read as SQL. The message mentions the id, never the credentials or the DSN.
<?php
declare(strict_types=1);
final class UserLookupFailed extends RuntimeException
{
}
function findUserEmail(PDO $pdo, int $id): ?string
{
try {
$statement = $pdo->prepare('SELECT email FROM users WHERE id = :id');
$statement->execute(['id' => $id]);
$email = $statement->fetchColumn();
return $email === false ? null : (string) $email;
} catch (PDOException $e) {
throw new UserLookupFailed("could not load user {$id}", 0, $e);
}
}Choosing the branch
The same method throws from both branches, and the branch is the message to the caller. A LogicException means the bug is upstream and no retry will help, so an alert is appropriate. A RuntimeException such as OverflowException is a legitimate state the interface has to report, so the caller shows the visitor an explanation. Teams often log the two at different levels for exactly this reason.
<?php
declare(strict_types=1);
final class Cart
{
private array $lines = [];
public function add(string $sku, int $quantity): void
{
// The caller got the call wrong. A person must fix the code.
if ($quantity < 1) {
throw new InvalidArgumentException("quantity must be 1 or more, got {$quantity}");
}
// There is a hard limit and we have reached it. Nothing is miscoded.
if (count($this->lines) >= 50) {
throw new OverflowException('a cart holds at most 50 lines');
}
$this->lines[$sku] = $quantity;
}
public function checkout(): void
{
// Calling this in the wrong order is a programming mistake.
if ($this->lines === []) {
throw new BadMethodCallException('checkout() called on an empty cart');
}
}
}
Discussion