Unreachable Catch Clause

ID

php.catch_blocks_able_catch_exceptions

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Reliability

Language

Php

Tags

reliability, unused-code

Description

Reports a catch clause that can never run because an earlier clause on the same try already catches everything it would.

PHP tests the clauses top to bottom and stops at the first whose type matches the thrown object. A broader type listed above a narrower one therefore makes the narrower clause dead — and it is the dead clause that usually holds the specific, useful handling.

Rationale

try {
    $this->fetch($url);
} catch (\RuntimeException $e) {
    $this->logger->error('request failed');
} catch (\OutOfBoundsException $e) {        // never runs
    $this->retryWithSmallerPage();
}

OutOfBoundsException extends RuntimeException, so the first clause absorbs it and the retry never happens. The code reads as if two cases were handled; one of them is not.

The trap grows with the hierarchy. catch (\Throwable $e) above anything makes every later clause dead, because only a Throwable can reach a catch at all. A union clause has the same effect for each type it lists:

try {
    $this->fetch($url);
} catch (\RuntimeException | \LogicException $e) {
    $this->logger->error('request failed');
} catch (\DomainException $e) {             // never runs — covered by \LogicException above
}

Listing the same type twice is the degenerate case, and usually the residue of a merge.

Remediation

Order the clauses from most specific to most general, so each one can be reached.

try {
    $this->fetch($url);
} catch (\OutOfBoundsException $e) {
    $this->retryWithSmallerPage();
} catch (\RuntimeException $e) {
    $this->logger->error('request failed');
}

If the two clauses turn out to do the same thing, delete the unreachable one, or merge the types into a single union clause:

try {
    $this->fetch($url);
} catch (\OutOfBoundsException | \JsonException $e) {
    $this->logger->error('request 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. Coverage is decided from two certain sources only: 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 project class extending a base from vendor/, for instance — the clause is left alone, because that base may be unrelated to the earlier one.

Redundancy within a single clause’s type list, such as catch (\Exception | \RuntimeException $e), is a different defect and is not reported here.

© 2026 Xygeni. All rights reserved