Catch Of A Type That Cannot Be Thrown
ID |
php.invalid_catch |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
Php |
Tags |
reliability, unused-code |
Description
Reports a catch clause naming a type that cannot be thrown, so the clause can never run.
Since PHP 7, only an object implementing Throwable — in practice a subclass of Exception or
of Error — can be thrown. A catch for anything else parses, looks like handling, and is dead:
the exception the block was written to absorb propagates past it.
Rationale
class ImportResult
{
public int $rows = 0;
}
enum Stage: string
{
case Parse = 'parse';
}
try {
$this->import($file);
} catch (ImportResult $e) { // never runs — ImportResult is not throwable
$this->logger->warning('import failed');
}
try {
$this->import($file);
} catch (Stage $e) { // never runs — an enum cannot implement Throwable
}
try {
$this->import($file);
} catch (\stdClass $e) { // never runs — a built-in that is not throwable
}
The mistake is easy to make and hard to see. A result object and the failure it reports often have
neighbouring names, an IDE completes the wrong one, and nothing complains: PHP does not check the
type of a catch at compile time, and the code only misbehaves on the path where the exception
actually fires — usually in production, where the handler was the point.
Remediation
Catch the type that is really thrown, and make sure a project exception type derives from a throwable base.
class ImportFailed extends \RuntimeException
{
}
try {
$this->import($file);
} catch (ImportFailed $e) {
$this->logger->warning('import failed', ['reason' => $e->getMessage()]);
}
When several unrelated failures share one handler, list them as a union rather than widening to
\Exception:
try {
$this->import($file);
} catch (ImportFailed | \JsonException $e) {
$this->logger->warning('import failed', ['reason' => $e->getMessage()]);
}
Configuration
The rule has no properties. What keeps it quiet is that it only reports a provable answer.
PHP resolves class names at run time through an autoloader, so one file rarely holds the whole
hierarchy. Two sources of truth are used, and both are certain: the language’s own Throwable /
Exception / Error and SPL hierarchy, which no project can change, and the extends /
implements clauses of the types declared in the same file. As soon as a chain leaves both — a
base class from vendor/, an interface declared elsewhere — the rule stays silent rather than
guessing.
Two further cases are deliberately left alone:
-
An interface is never reported.
catch (Marker $e)is live as long as some class implements bothMarkerandThrowable, and that class may be declared anywhere. -
A name declared nowhere reachable belongs to
php.undefined_class. The two rules are disjoint by construction: this one requires the type to be declared before it says anything about it.