Types & Error Handling
A type declaration in PHP is not a hint and not an annotation: it is a runtime check performed at the function boundary on every single call. The compiler infers nothing and erases nothing — it merely records the declaration, and the engine verifies the value at the moment the argument is passed. Two consequences follow. First, the coercion mode is decided by the file that makes the call: declare(strict_types=1) is per-file and governs that file's own calls, so a strict file calling a non-strict library is checked strictly, while a non-strict caller of your strict library still gets coercion on the way in. Second, violating a declaration is not "an analyzer warning" — it is a thrown TypeError.
Since PHP 7 the error model has been split in two, and that is the other thing probed here. Throwable is an interface, and beneath it are two parallel branches: Exception — application failures you are expected to handle — and Error — engine failures (TypeError, ValueError, DivisionByZeroError). The branches do not intersect, so catch (Exception $e) never catches an Error, and what you catch at the application boundary is \Throwable. Alongside them lives a third, entirely different world — engine diagnostics (warnings, notices, deprecations): these are not thrown objects, catch cannot see them at all, and the only way to turn them into exceptions is set_error_handler(). The traps are worth naming upfront: a return inside finally overrides the value from try and swallows an exception thrown there; PHP 8.5's #[\NoDiscard] raises a Warning, not a fatal error, and is silenced by the new (void) cast; and an attribute does nothing by itself until something reads it through Reflection. The layer-by-layer breakdown is below.
Topic map
- Type declarations — union, intersection, nullable, and
never; the boundary check on every call; and signature variance on override. - strict_types and scalar coercion — why the directive is per-file, why the call site decides, and the one widening that is allowed even in strict mode.
- Errors versus exceptions — the three tiers of diagnostics, what PHP 7 made catchable, what stayed fatal, and what
#[\NoDiscard]does. - The Throwable hierarchy — the
Throwableinterface and its two branches, whycatch (Exception)missesError, and how to design a module's exception hierarchy. - try, catch, and finally — why the return value is computed before
finally, how areturnfromfinallyloses an exception, and what multi-catch does. - Custom error handlers — what
set_error_handler()intercepts, howset_exception_handler()differs, and how to see a fatal error at all. - Attributes and Reflection — why an attribute is inert without a reader, why it beats a docblock annotation, and what happens inside
newInstance().
Common Mistakes and Traps
| Mistake | Consequence | |||
|---|---|---|---|---|
Thinking the declaring file's strict_types governs the caller | The reverse: the mode comes from the file that makes the call — your strict library is not protected from non-strict callers | |||
Expecting strict_types to affect class, array, or iterable declarations | The directive changes the behavior of scalar declarations only; the rest are always checked strictly | |||
Adding declare(strict_types=1) to every file at once | Silent coercions turn into TypeErrors en masse — straight in production | |||
| Reading `int\ | string as "may also be null`" | A union is not nullable by itself: you need an explicit `int\ | string\ | null` |
Reading the & in Countable&Iterator as a union | An intersection demands all the types at once; only class and interface names are allowed in it | |||
Treating never as a synonym for void | A void function returns, just with no value; a never function does not return at all | |||
Expecting catch (Exception $e) to pick up a TypeError | Error sits in a parallel branch under Throwable — catch \Throwable | |||
Expecting catch to catch a warning or a notice | A diagnostic is not a thrown object; only set_error_handler() turns it into an exception | |||
Expecting set_error_handler() to catch a fatal error | Memory exhaustion and the time limit never reach it — they are visible only from register_shutdown_function | |||
Believing a return in try skips finally | finally always runs: the value is computed, set aside, and only then does the function return | |||
Doing a return (or throw) from finally | The value from try is overridden, and an exception thrown there is silently lost | |||
Expecting a fatal error from #[\NoDiscard] | It raises a Warning; the way to discard the result deliberately is the new (void) cast, not @ | |||
Silencing a discarded result with the @ operator | @ suppresses diagnostics indiscriminately and hides real problems | |||
| Expecting an attribute to run itself when the class is loaded | An attribute is inert: the engine parses it and stops — a Reflection reader is required | |||
| Building an exception hierarchy around where the failure arose | The caller cannot choose a catch — a hierarchy is built around how the caller must react | |||
| Putting a failure code inside the message string | The caller is left parsing text with str_contains(); structured data belongs in a typed property |
Interview relevance
This is the topic where candidates are caught on two precise mechanisms. The first: "where exactly does declare(strict_types=1) take effect?" — the right answer sounds like "per file, and the file that makes the call decides", not "it enables strict typing in the project". The second: "will catch (Exception $e) catch a method call on null?" — the right answer is "no, that is an Error from the parallel branch". Both questions probe a model rather than a vocabulary: does the candidate understand that types are checked at runtime at the call boundary, and that since PHP 7 engine failures became thrown objects just like application exceptions — only on a different branch.
Typical checks:
- What a union accepts and what it rejects; why
?intis exactlyint|null. - What an intersection type demands, and why
int&stringis impossible. - How
neverdiffers fromvoid. - Where
strict_typestakes effect, and whose file decides. - The difference between a warning, a fatal error, and an exception; what PHP 7 made catchable.
- The relationship of
Throwable,Error, andException— and whycatch (Exception)does not cover both worlds. - The evaluation order of
returnandfinally; what happens to an exception on areturnfromfinally. - What
set_error_handler()intercepts, and why you rethrow warnings as anErrorException. - Why attributes exist when docblocks already did, and what
getAttributes()/newInstance()do.
Common wrong answer: "strict_types enables strict typing across the whole application." It is usually followed by the plan "add the directive to all 400 files and see what happens" — and a production incident, because every numeric string from a form and from a database column that used to coerce silently to int now throws a TypeError. The second classic failure is a single catch (\Exception $e) at the application boundary: an engine failure such as a method call on null passes straight through it and becomes a white screen, and there is no correlation id by which support could find the log line at all.