Five Ways to Write a PHP Type
PHP’s type system is grown up. PHP 7.0 started the ball rolling with parameter and return types. PHP 7.4 added property types. PHP 8.3 added the class constant types, though we’ll omit this for now. PHP has quietly accumulated five distinct syntaxes where you can write something that looks like a type: parameter types, property types, return types, catch clauses, and the instanceof operator. Five positions, one similar looking syntax, and, wait for the end, five different rulebooks.
If you have ever pasted a type from a catch clause into a property declaration and watched PHP throw a compile error in agony, you’ve already bumped into one of those rulebooks. This post covers all five of them.
A quick vocabulary note
PHP’s type system is made of several distinct categories:
- Named types: a class or interface name:
MyException,Countable,DateTimeInterface. - Scalar types:
int,float,string,bool. - Pseudo-types:
mixed,callable,iterable,object: categories of value, not class names. - Literal types:
true(PHP 8.2) andfalse(PHP 8.0):bool, narrowed down to a single value. - Relative types:
self,parent,static: resolved against the class where the code is written. - Return-only types:
voidandnever: they describe what a function does at exit, not what a value holds. - Composite types: union (
A|B), intersection (A&B), and DNF ((A&B)|C).
Each position has its own opinion about which of these it will tolerate. None of them accepts the full list.
Parameter types: the generous generalist
Parameters accept the widest range. Scalars, named classes, pseudo-types, nullables, literals, relative types, unions, intersections, DNF. The only notable absences are void, never, and static.
void and never make no sense here: you cannot pass “no value”: what about not passing it AT ALL? And there is no such thing as a value of type never. static is slightly more philosophical. It was deliberately excluded from parameters when it was added as a return type in PHP 8.0. The reason involves variance, which is its own article, best read with coffee.
Parameters are contravariant in inheritance: a child class may widen a parameter type. This is the one type position where being less precise is actually the correct move.
<?php
function process(
int|string $id,
(A&B)|null $filter = null,
callable $callback,
self $other,
): void { ... }
?>
Property types: parameters, minus one
Property types accept almost everything parameters do, with one exception: callable is forbidden.
<?php
class Cache {
public callable $fn; // Fatal error: Property types cannot be callable
}
?>
The reason is mildly amusing. PHP can verify that a value is callable at the moment of assignment: an array ['MyClass', 'method'] satisfies the check, but callability is not a durable property. If MyClass is later unloaded, the property is now holding a non-callable value that passed its type check perfectly. PHP decided not to carry that verification burden forward on every property read. The result is that callable works on parameters and return types but silently fails on properties.
This is the kind of asymmetry that looks like a bug until you understand it, and then it looks like a reasonable trade-off, and then you still find it annoying.
Property types are also invariant in inheritance: unlike parameters and return types, a child class cannot change a property’s type. Not wider, not narrower. Exactly the same. Full stop.
Return types: where void and never earn their keep
Return types accept everything parameters accept, plus static, void, and never.
void marks a function that does not return a value. You may still write return; inside it, which is a detail that confuses people for about ten minutes and then becomes natural. never is the more dramatic sibling: it marks a function that never returns at all: it throws, calls die(), or loops forever. It is the bottom type; no value is of type never, and nothing needs to be checked.
static as a return type means “an instance of whatever class was actually called at runtime.” It is not the same as self which is resolved at compile time, static follows late static binding. The distinction matters for factory methods and fluent interfaces in inheritance hierarchies, and it is explained thoroughly in the PHP manual if you want the full version.
Return types are covariant: each generation of a class may return something more specific than its parent declared. The arrow of precision points downward.
catch clauses: union types, but stop right there
catch is where the first serious divergence appears. The clause accepts types, but it is not a type declaration. It is a selector: when an exception propagates up the call stack, PHP walks through the catch clauses in order and takes the first one whose type the thrown object satisfies. The type exists to be matched against a live object, and that shapes what it will accept.
Named classes are welcome, with a constraint: the class must implement Throwable. Trying to catch an arbitrary class that has nothing to do with exceptions is a compile-time error, not a runtime surprise. PHP checks this before your code runs.
Scalars, pseudo-types, null, literals, relative types, void, never: all are rejected. There is nothing to match against. You cannot throw a scalar, so you cannot catch one. catch (mixed $e) sounds almost reasonable until you remember that it would mean “catch everything,” and that is what catch with a \Throwable type already does.
Union types, however, are accepted.
<?php
try {
riskyOperation();
} catch (IOException|NetworkException $e) {
retry();
}
?>
And here is the historical footnote worth pausing on: this syntax has been valid since PHP 7.1, which is three major versions before union types arrived everywhere else in PHP 8.0. catch was PHP’s first experiment with multi-type syntax, quietly living in exception handling while the rest of the language had not caught up yet. It was years ahead of its time and then, as these things go, stopped where it was.
Because intersection types and DNF do not work in catch. You cannot write catch ((Loggable&Throwable) $e). The runtime matching logic handles one class name at a time; the engine has simply never been extended to handle conjunctions. In practice this is rarely a hardship: a shared interface on the exception hierarchy covers most cases. But the gap is real.
One genuine quality-of-life improvement came with PHP 8.0: the variable is now optional. catch (RuntimeException) without the $e is valid when you have nothing useful to do with the exception object. The RFC for this feature is three sentences long, which feels right.
instanceof: the most opinionated of all
instanceof is not a declaration. It is a boolean expression that asks, in real time, whether a value belongs to a class or interface. It never throws. If the left-hand operand is not an object, it returns false quietly rather than raising an error. If the named class does not exist, it returns false rather than halting.
What it accepts on the right-hand side is more restrictive than any other syntax.
Named classes and interfaces: yes. Scalars: no $x instanceof int is a parse error, and it makes sense that such syntax does not make sense (right?). Pseudo-types: also no, and this is where people stumble. $x instanceof object is rejected, even though testing whether something is an object is a perfectly ordinary need. For that, the answer is is_object(). Similarly, $x instanceof mixed, $x instanceof iterable, and $x instanceof callable are all parse errors.
Composite types: none. No union, no intersection, no DNF. There is no $x instanceof A|B. You write $x instanceof A || $x instanceof B instead, which is arguably clearer, and you think for a moment about whether that is enough.
But instanceof has two things no other type position has.
First, it accepts relative types, and uniquely, all three of them, including static.
<?php
class Base {
public function isClone(object $other): bool {
return $other instanceof static; // valid; no other type position allows static here
}
}
?></code></pre></div>
<code>instanceof</code> is the only type position where <code>static</code> works outside of a return type. <code>self</code> and <code>parent</code> also work, inside a class context. Elsewhere in the type system, relative types belong to declarations; here, they belong to a runtime check.
Second, <code>instanceof</code> accepts a variable on the right-hand side.
<div><pre><code class="language-php">[php]
<?php
$className = SomeRegistry::getExpectedClass();
if ($result instanceof $className) { ... }
?>
This is the only place in PHP’s type system where the type itself is determined at runtime, not at parse time. No other syntax supports a dynamic class name. It is the feature that makes instanceof genuinely useful for plugin systems, dispatchers, and deserialization layers, and it is the feature that makes static analysers quietly recalibrate their expectations.
The full picture
| Parameter | Property | Return | catch |
instanceof |
|
|---|---|---|---|---|---|
| Named class/interface | ✓ | ✓ | ✓ | ✓ Throwable only | ✓ |
| Scalar | ✓ | ✓ | ✓ | ✗ | ✗ |
null |
✓ | ✓ | ✓ | ✗ | ✗ |
array |
✓ | ✓ | ✓ | ✗ | ✗ |
mixed |
✓ | ✓ | ✓ | ✗ | ✗ |
object |
✓ | ✓ | ✓ | ✗ | ✗ |
iterable |
✓ | ✓ | ✓ | ✗ | ✗ |
callable |
✓ | ✗ | ✓ | ✗ | ✗ |
void |
✗ | ✗ | ✓ | ✗ | ✗ |
never |
✗ | ✗ | ✓ | ✗ | ✗ |
true / false |
✓ | ✓ | ✓ | ✗ | ✗ |
self / parent |
✓ | ✓ | ✓ | ✗ | ✓ |
static |
✗ | ✗ | ✓ | ✗ | ✓ |
Union A\|B |
✓ | ✓ | ✓ | ✓ | ✗ |
Intersection A&B |
✓ | ✓ | ✓ | ✗ | ✗ |
DNF (A&B)\|C |
✓ | ✓ | ✓ | ✗ | ✗ |
Nullable ?T |
✓ | ✓ | ✓ | ✗ | ✗ |
| Dynamic variable | ✗ | ✗ | ✗ | ✗ | ✓ |
| Variance | contra | invariant | co |
The underlying pattern
Step back and a structure emerges. The five positions sit on a spectrum from contract to probe.
Parameters, properties, and return types are contracts. They define what a function or class commits to accepting or producing, they are checked at call time, assignment time, or return time, and they participate in the inheritance system with variance rules. They need to be as expressive as possible, so they accept nearly the full vocabulary.
catch is a selector. It runs at exception time, it matches a live thrown object against a list of types, and it needs to identify concrete class membership. That rules out pseudo-types, scalars, and composites beyond union. The fact that it predates the general union type system is a piece of PHP history that most people have forgotten.
instanceof is a probe. It asks a yes-or-no question about a single value, it never fails loudly, and its only business is matching class identity. It is the most restrictive of the five positions in what it accepts as a type. Nut the most flexible in how that type can be expressed, including the unique ability to take it from a variable at runtime.
The syntax looks identical in all five places. The semantics are not. Copy a type from a catch clause to a property and PHP will accept it quietly, and add a simple ? or |null to the catch clause type and the engine will refuse it at compile time. Try (A&B) in a catch clause or callable on a property and PHP will tell you so before your first test ever runs.
Once you have the map, the error messages stop feeling arbitrary. Each position is its own small dialect of the same language, with its own vocabulary and its own limits and they have good reasons for every rule they keep.
Related reading: PHP type declarations manual · Exceptions · instanceof · DNF types RFC · Intersection types RFC · Anonymous catch RFC

