pepakriz/phpstan-exception-rules
PHPStan extension that adds custom rules for analyzing exceptions. It helps catch improper throwing/catching, missing @throws annotations, and other exception-related issues to improve correctness and maintainability in PHP codebases.
This extension provides following rules and features:
@throws annotation when some checked exception is thrown (examples)@throws annotation detection (examples)@throws annotation detection (examples)@throws annotations in subtypes (examples)@throws annotation variance validation (examples)Features and rules provided by PHPStan core (we rely on):
@throws annotation must contain only valid Throwable typesThrowableTo use this extension, require it in Composer:
composer require --dev pepakriz/phpstan-exception-rules
And include and configure extension.neon in your project's PHPStan config:
includes:
- vendor/pepakriz/phpstan-exception-rules/extension.neon
parameters:
exceptionRules:
reportUnusedCatchesOfUncheckedExceptions: false
reportUnusedCheckedThrowsInSubtypes: false
reportCheckedThrowsInGlobalScope: false
checkedExceptions:
- RuntimeException
You could use uncheckedExceptions when you prefer a list of unchecked exceptions instead. It is a safer variant, but harder to adapt to the existing project.
parameters:
exceptionRules:
uncheckedExceptions:
- LogicException
- PHPUnit\Framework\Exception
checkedExceptionsanduncheckedExceptionscannot be configured at the same time
If some third-party code defines wrong throw types (or it doesn't use @throws annotations at all), you could override definitions like this:
parameters:
exceptionRules:
methodThrowTypeDeclarations:
FooProject\SomeService:
sendMessage:
- FooProject\ConnectionTimeoutException
methodWithoutException: []
functionThrowTypeDeclarations:
myFooFunction:
- FooException
In some cases, you may want to ignore exception-related errors as per class basis, as is usually the case for testing:
parameters:
exceptionRules:
methodWhitelist:
PHPUnit\Framework\TestCase: '#^(test|(setup|setupbeforeclass|teardown|teardownafterclass)$)#i'
Dynamic throw type extensions - If the throw type is not always the same, but depends on an argument passed to the method. (Similar feature as Dynamic return type extensions)
There are interfaces, which you can implement:
Pepakriz\PHPStanExceptionRules\DynamicMethodThrowTypeExtension - service tag: exceptionRules.dynamicMethodThrowTypeExtensionPepakriz\PHPStanExceptionRules\DynamicStaticMethodThrowTypeExtension - service tag: exceptionRules.dynamicStaticMethodThrowTypeExtensionPepakriz\PHPStanExceptionRules\DynamicConstructorThrowTypeExtension - service tag: exceptionRules.dynamicConstructorThrowTypeExtensionPepakriz\PHPStanExceptionRules\DynamicFunctionThrowTypeExtension - service tag: exceptionRules.dynamicFunctionThrowTypeExtensionand register as service with correct tag:
services:
-
class: App\PHPStan\EntityManagerDynamicMethodThrowTypeExtension
tags:
- exceptionRules.dynamicMethodThrowTypeExtension
There are 2 types of exceptions:
@throws annotation (see below).@throws annotation. Also if you call an method with that annotation and do not catch the exception, you must propagate it in your @throws annotation. This, of course, may spread quickly. When this exception is handled (caught), it is important for programmer to immediately know what case is handled and therefore all used RuntimeExceptions are inherited from some parent and have very descriptive class name (so that you can see it in catch construct) - for example CannotCloseAccountWithPositiveBalanceException. The message is not that important since you should always catch these exceptions somewhere, but in our case we often use that message in API output and display it to end-user, so please use something informative for users in that cases (you can pass custom arguments to constructor (e.g. entities) to provide better message). Sometimes you can meet a place where you know that some exception will never be thrown - in this case you can catch it and wrap to LogicException (because when it is thrown, it is a programmer's fault).It is always a good idea to wrap previous exception so that we do not lose information of what really happened in some logs.
// no throws annotation
public function decide(int $arg): void
{
switch ($arg) {
case self::ONE:
$this->decided()
case self::TWO:
$this->decidedDifferently()
default:
throw new LogicException("Decision cannot be made for argument $arg because of ...");
}
}
/**
* @return mixed[]
*
* @throws PrintJobFailedException
*/
private function sendRequest(Request $request): array
{
try {
$response = $this->httpClient->send($request);
return Json::decode((string) $response->getBody(), Json::FORCE_ARRAY);
} catch (GuzzleException | JsonException $e) {
throw new PrintJobFailedException($e);
}
}
class PrintJobFailedException extends RuntimeException
{
public function __construct(Throwable $previous)
{
parent::__construct('Printing failed, remote printing service is down. Please try again later', $previous);
}
}
False positive when a method does not execute declared function:
/**
* @throws FooRuntimeException false positive
*/
public function createFnFoo(int $arg): callable
{
return function () {
throw new FooRuntimeException();
};
}
But most of use-cases just works:
/**
* @param string[] $rows
* @return string[]
*
* @throws EmptyLineException
*/
public function normalizeRows(array $rows): array
{
return array_map(function (string $row): string {
$row = trim($row);
if ($row === '') {
throw new EmptyLineException();
}
return $row;
}, $rows);
}
Catch statement does not know about runtime subtypesThis case is detected by rule, so you will be warned about a potential risk.
Runtime exception is absorbed:
// @throws phpdoc is not required
public function methodWithoutThrowsPhpDoc(): void
{
try {
throw new RuntimeException();
$this->dangerousCall();
} catch (Throwable $e) {
throw $e;
}
}
As a workaround you could use custom catch statement:
/**
* @throws RuntimeException
*/
public function methodWithThrowsPhpDoc(): void
{
try {
throw new RuntimeException();
$this->dangerousCall();
} catch (RuntimeException $e) {
throw $e;
} catch (Throwable $e) {
throw $e;
}
}
How can I help you explore Laravel packages today?