Types & Error Handling
The PHP type and error model — errors versus exceptions, the Throwable hierarchy, try/finally semantics, union and intersection type declarations, strict_types coercion, attributes read through Reflection, and custom error handlers.
17 questions
JuniorTheoryVery commonWhat is the difference between a PHP warning, a fatal error, and an exception?
What is the difference between a PHP warning, a fatal error, and an exception?
A warning or notice is a diagnostic: the engine reports it and execution carries on. A fatal error stops the script outright. An exception is an object you throw; it unwinds the stack until a matching catch handles it, and if nothing does, it ends the script as a fatal error.
Common mistakes
- ✗Thinking
catch (Exception $e)will intercept a warning or a notice - ✗Believing a warning stops execution the way a fatal error does
- ✗Assuming an uncaught exception is silently ignored rather than fatal
Follow-up questions
- →Which failures did PHP 7 turn into throwable
Errorobjects, and which stayed fatal? - →How do you make warnings visible in development but logged silently in production?
JuniorTheoryCommonWhat is a PHP attribute, and how does code read one at runtime?
What is a PHP attribute, and how does code read one at runtime?
An attribute is structured metadata written as #[Route('/users')] above a class, method, property or parameter. The engine parses it and stops there — you read it via Reflection: getAttributes() returns the declarations and newInstance() builds the attribute object. An attribute class carries #[Attribute].
Common mistakes
- ✗Expecting an attribute to do something on its own without a reader calling Reflection
- ✗Thinking an attribute is a comment the engine discards, like a docblock
- ✗Believing attributes may sit only on a class and not on a method or property
Follow-up questions
- →What does
newInstance()give you that the raw attribute arguments do not? - →When does the class named by an attribute actually get autoloaded?
JuniorTheoryCommonWhat is the difference between ?int and a union type that includes null?
What is the difference between ?int and a union type that includes null?
?int is shorthand for int|null — the same type, the same runtime check. The difference is reach: ? prefixes exactly one type, so a nullable union of two types must be spelled out as int|string|null. The two forms may not be mixed, and ?int|string is a parse error.
Common mistakes
- ✗Confusing a nullable type with an optional parameter that may be omitted
- ✗Thinking
?intaccepts any falsy value rather than exactlyintornull - ✗Trying to write
?int|stringinstead ofint|string|null
Follow-up questions
- →Does a
?intparameter with no default still have to be passed at the call site? - →What does a nullable return type say about a function that finds nothing?
JuniorTheoryCommonHow are Throwable, Error and Exception related in PHP?
How are Throwable, Error and Exception related in PHP?
Throwable is the interface every throwable implements. Below it sit two separate branches: Exception for application failures you are meant to handle, and Error for engine faults such as TypeError. So catch (Exception $e) never catches an Error — catch Throwable for both.
Common mistakes
- ✗Expecting
catch (Exception $e)to pick up aTypeErroror anotherError - ✗Thinking
ErrorextendsExceptionrather than sitting on a parallel branch - ✗Treating
Throwableas a class you can extend instead of an interface
Follow-up questions
- →When is catching
\Throwablethe right choice, and when is it too broad? - →Why can user code not implement
Throwabledirectly on an arbitrary class?
JuniorTheoryCommonWhat does a union type declaration like int|string allow, and what does it reject?
What does a union type declaration like int|string allow, and what does it reject?
A union accepts a value matching any one of the listed types and rejects the rest with a TypeError at the boundary. int|string takes an int or a string but not null — allowing null means writing int|string|null. The check happens at the call site, on every call, at runtime.
Common mistakes
- ✗Assuming a union is implicitly nullable and accepts
nullwithout listing it - ✗Thinking the union is a hint the engine does not check at runtime
- ✗Reading
|as an intersection — a value satisfying all the listed types at once
Follow-up questions
- →Why can
voidandnevernot appear inside a union type? - →How does
strict_typeschange which values aint|stringunion actually accepts?
MiddleTheoryCommonWhich failures became catchable in PHP 7, and which are still an uncatchable fatal error?
Which failures became catchable in PHP 7, and which are still an uncatchable fatal error?
PHP 7 turned most engine faults into thrown Error objects — a method call on null, a TypeError, a DivisionByZeroError — so catch (\Error) or catch (\Throwable) handles them. What stayed a true fatal is what the engine cannot continue from: memory exhaustion, a time-limit abort, a parse error.
Common mistakes
- ✗Thinking
catch (Exception $e)picks up an engineError - ✗Expecting memory exhaustion or an execution timeout to be catchable
- ✗Treating a fatal error and a thrown
Erroras the same thing
Follow-up questions
- →How do you still report a fatal error to your logger when nothing can catch it?
- →Why does a parse error in an included file never reach your
try/catch?
MiddleDesignCommonYou maintain a payments module. Today it throws a plain \Exception with a message string from every failure path — a declined card, an unreachable gateway, a malformed amount, a duplicate charge — and callers write catch (\Exception $e) and then inspect the message text with str_contains(). You are asked to design a proper exception hierarchy for the module. Describe what you would base the hierarchy on, which failures deserve a class of their own and which do not, what the module's callers should be able to catch without knowing its internals, how you would carry structured data such as the gateway's decline code, and how you would later add new failure types without breaking those callers.
You maintain a payments module. Today it throws a plain \Exception with a message string from every failure path — a declined card, an unreachable gateway, a malformed amount, a duplicate charge — and callers write catch (\Exception $e) and then inspect the message text with str_contains(). You are asked to design a proper exception hierarchy for the module. Describe what you would base the hierarchy on, which failures deserve a class of their own and which do not, what the module's callers should be able to catch without knowing its internals, how you would carry structured data such as the gateway's decline code, and how you would later add new failure types without breaking those callers.
Base the hierarchy on how the CALLER must react, not on where the failure arose. Publish one base exception (or a marker interface) the whole module throws, so a caller can catch the module without knowing it. Split only where the handling differs. Carry the decline code as a typed property, never inside the message.
Common mistakes
- ✗Building the hierarchy around where the failure arose rather than how the caller must react
- ✗Encoding structured data such as a decline code inside the message string
- ✗Exposing no shared base class, so callers are forced to catch
\Throwable
Follow-up questions
- →What breaks in a caller when you insert a new class into the middle of the hierarchy?
- →When is a marker interface a better public contract than a base exception class?
MiddleTheoryCommonWhere exactly does declare(strict_types=1) change behaviour, and whose file decides?
Where exactly does declare(strict_types=1) change behaviour, and whose file decides?
It changes only scalar type declarations, per file, and the deciding file is the one making the CALL — not the one declaring the function. So a strict file calling a coercive library is still checked strictly, and a coercive caller into your strict library still gets coercion.
Common mistakes
- ✗Thinking the declaring file's
strict_typesgoverns the caller - ✗Expecting
strict_typesto affect class,arrayoriterabletype declarations - ✗Believing the directive is global to the request rather than per-file
Follow-up questions
- →Why must
declare(strict_types=1)be the very first statement in the file? - →What happens to an internal function like
strlen()when the calling file is strict?
JuniorTheoryOccasionalWhat does an intersection type such as Countable&Iterator require of a value?
What does an intersection type such as Countable&Iterator require of a value?
An intersection accepts only a value that satisfies every listed type at once — an object implementing both Countable and Iterator. Only class and interface names may appear in one; scalars, null and array are rejected, so int&string is a compile error. It arrived in PHP 8.1.
Common mistakes
- ✗Reading
&as a union — a value matching any one of the listed types - ✗Trying to put scalars such as
int&stringinto an intersection - ✗Expecting an intersection to accept
nullwithout an explicit nullable form
Follow-up questions
- →Why is
?Countable&Iteratornot a legal declaration in PHP 8.1? - →When is an intersection type a better contract than declaring a new interface?
JuniorTheoryOccasionalWhat does the never return type declare about a function?
What does the never return type declare about a function?
never says the function never returns to its caller: it either throws, calls exit(), or loops forever. It is not void — a void function does return, just with no value. Analysers treat the code after such a call as unreachable, and a return inside a never function is an error.
Common mistakes
- ✗Treating
neveras a synonym forvoid - ✗Writing a
returnstatement inside a function declarednever - ✗Expecting code placed after a
nevercall to still be reachable
Follow-up questions
- →Why is
neveruseful on a function whose only job is to throw a domain exception? - →How does declaring
neverchange what a static analyser concludes about the caller?
MiddleTheoryOccasionalWhy did PHP add attributes when docblock annotations already existed?
Why did PHP add attributes when docblock annotations already existed?
A docblock is a comment: the engine throws it away, so every framework had to re-parse the source text with its own regex, and a typo failed silently. An attribute is real syntax — parsed by the engine, resolved as a class name, autoloaded, and exposed through Reflection. A misspelled attribute class is an error you can find.
Common mistakes
- ✗Thinking a docblock annotation is parsed by the engine rather than by a library's regex
- ✗Expecting an attribute to execute on its own when the class is loaded
- ✗Believing a misspelled attribute class fails as silently as a misspelled annotation
Follow-up questions
- →What happens when the class named by an attribute does not exist, and when do you find out?
- →What does
newInstance()validate that reading the raw attribute arguments does not?
MiddleTheoryOccasionalWhat does finally do when the try block has already executed a return?
What does finally do when the try block has already executed a return?
The return value is evaluated and set aside, then finally runs, then the function returns. So finally always executes — on a normal return, on an exception, and on a break. If finally itself returns, its value overrides the one from try, and an exception thrown in try is discarded.
Common mistakes
- ✗Thinking a
returnintryskipsfinally - ✗Not realising a
returninsidefinallyoverrides the value returned fromtry - ✗Forgetting that returning from
finallyswallows an exception thrown intry
Follow-up questions
- →What is returned when both
tryandfinallycontain areturnstatement? - →Why is putting a
returninsidefinallytreated as a code smell?
MiddleTheoryOccasionalWhat does set_error_handler() catch, and why convert those errors into exceptions?
What does set_error_handler() catch, and why convert those errors into exceptions?
set_error_handler() intercepts the engine's diagnostics — warnings and notices — not exceptions, and not a fatal error, which it cannot recover from. Handlers usually rethrow them as ErrorException, so one try/catch covers both worlds and a warning can no longer be ignored.
Common mistakes
- ✗Expecting
set_error_handler()to catch a fatal error - ✗Confusing it with
set_exception_handler(), which deals with uncaught exceptions - ✗Registering it late, after code that has already warned
Follow-up questions
- →What does
register_shutdown_function()add for a fatal error that no handler can catch? - →What does an
ErrorExceptioncarry that a plain warning message does not?
SeniorTheoryOccasionalWhat may an overriding method change about its parameter and return types, and why?
What may an overriding method change about its parameter and return types, and why?
PHP allows covariant return types and contravariant parameter types, both enforced by the engine at compile time. An override may narrow the return — return Dog where the parent returned Animal — and may widen a parameter, accepting Animal where the parent took Dog. The rule is Liskov substitution.
Common mistakes
- ✗Swapping the directions — narrowing a parameter or widening a return type
- ✗Thinking the engine only checks that the two signatures match exactly
- ✗Believing the variance rules are advisory and enforced by static analysers alone
Follow-up questions
- →What breaks for a caller when a child narrows a parameter type?
- →How does
staticas a return type interact with the covariance rule?
MiddleTheoryRareWhat does the PHP 8.5 attribute #[\NoDiscard] do when a return value is ignored?
What does the PHP 8.5 attribute #[\NoDiscard] do when a return value is ignored?
#[\NoDiscard] marks a function whose return value must be used. Calling it and throwing the result away emits a Warning at runtime, so a forgotten result stops being silent. When discarding really is intended, the new (void) cast silences it: (void) $conn->close();.
Common mistakes
- ✗Expecting a discarded result to be a fatal error rather than a Warning
- ✗Silencing it with the
@operator instead of the(void)cast - ✗Assuming the attribute is inert at runtime and read only by static analysers
Follow-up questions
- →What does the
(void)cast do to the value it is applied to? - →Why is a Warning, rather than a
TypeError, the right diagnostic level for a discarded result?
SeniorDesignRareA Laravel application logs nothing useful. Every controller wraps its body in try/catch (\Exception $e), echoes a message, and returns a 200 with a JSON body saying error. Support cannot correlate a user complaint with a log line, the same failure is caught and formatted differently in eight places, and an engine Error such as a method call on null escapes every one of those blocks and shows a blank page. Design centralised exception handling and logging for this application: where the handling should live, what a controller should still catch itself, what the response should carry, what the log line must contain to be usable in support, and how you keep an unexpected engine fault from reaching the user as a blank page.
A Laravel application logs nothing useful. Every controller wraps its body in try/catch (\Exception $e), echoes a message, and returns a 200 with a JSON body saying error. Support cannot correlate a user complaint with a log line, the same failure is caught and formatted differently in eight places, and an engine Error such as a method call on null escapes every one of those blocks and shows a blank page. Design centralised exception handling and logging for this application: where the handling should live, what a controller should still catch itself, what the response should carry, what the log line must contain to be usable in support, and how you keep an unexpected engine fault from reaching the user as a blank page.
Handle it once, at the framework boundary: a single handler that catches \Throwable, so engine Errors are covered too. Controllers catch only what they can genuinely recover from. The handler maps the exception to a status code and a stable error shape, attaches a correlation id it also returns to the user, and logs that id with the trace and request context.
Common mistakes
- ✗Catching
\Exceptionat the boundary, so an engineErrorstill escapes - ✗Returning the raw exception message or the stack trace to the client
- ✗Logging without a correlation id the user can quote back to support
Follow-up questions
- →Why must the boundary handler catch
\Throwablerather than\Exception? - →What belongs in the log line but must never appear in the response body?
SeniorDesignRareYou want declare(strict_types=1) in a 400-file legacy codebase that carries type declarations on maybe a third of its functions and passes numeric strings around freely — form input, database columns read as strings, ids that are sometimes int and sometimes '42'. Adding the declare line to every file at once would turn silent coercions into TypeErrors in production. Describe how you would roll strict types out safely: what order you would convert the files in, given that the directive is per-file and decided at the call site, how you would find the coercions that would start throwing before they ever reach production, what you would do about a function that legitimately receives both an int and a string, and what stops the codebase from drifting back.
You want declare(strict_types=1) in a 400-file legacy codebase that carries type declarations on maybe a third of its functions and passes numeric strings around freely — form input, database columns read as strings, ids that are sometimes int and sometimes '42'. Adding the declare line to every file at once would turn silent coercions into TypeErrors in production. Describe how you would roll strict types out safely: what order you would convert the files in, given that the directive is per-file and decided at the call site, how you would find the coercions that would start throwing before they ever reach production, what you would do about a function that legitimately receives both an int and a string, and what stops the codebase from drifting back.
Roll it out file by file, starting at the LEAVES — the directive is decided by the CALLING file, so enabling it in a leaf that calls little turns almost nothing into a TypeError. Find the coercions ahead of production with static analysis plus the test suite run in strict mode. Where a function honestly takes both, declare int|string and normalise inside it.
Common mistakes
- ✗Thinking the declare in an entry-point file cascades into the files it includes
- ✗Starting at the top of the call graph, where the newly strict file calls the most code
- ✗Dropping a type declaration to dodge a
TypeErrorinstead of declaring a union and normalising
Follow-up questions
- →Which file decides whether an argument is coerced — the caller or the callee?
- →What CI check keeps a new file from shipping without the declaration?