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 both Marker and Throwable, 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.

© 2026 Xygeni. All rights reserved