Exceptions designed for static analysis and easy usage
- Setup
- Fluent interface
- Types of exceptions
- Messages
- Suppressed exceptions
- Exception suffix
- Exceptions as part of the function signature
Install with Composer
composer require orisai/exceptions
All of our exceptions use ConfigurableException
trait which allows adding message, code and previous exception through
fluent interface.
throw (new ExampleError())
->withMessage('Error message')
->withPrevious($previousException)
->withCode(666);
It nicely works in combination with static constructor which is implemented by all unchecked exceptions and which is recommended to implement by checked exceptions
throw (new ExampleError())
->withMessage('Error message');
turns into
throw ExampleError::create()
->withMessage('Error message');
Exceptions which are used to represent an error caused by user interaction.
- All of them must implement interface
CheckedException
and should extend\RuntimeException
.- You may also extend
DomainException
which implementsCheckedException
, extends\RuntimeException
, disables default constructor and usesConfigurableException
trait.
- You may also extend
- Checked exceptions are intended to be handled. They should always be caught or listed in annotations and caught in higher layers.
- The way they are handled is up to you - report the error to user, use a fallback strategy, log the error, or a combination thereof.
- They must be part of the function signature.
- They should be always as specific as possible.
- e.g.
ExpiredToken
exception which implementsInvalidToken
interface allows more granular handling thanInvalidToken
exception which does not explain what exactly is wrong with the token. - In case exception is part of an interface then interface signature should specify supertype of
exception (
InvalidToken
) which implementations can throw instead of subtypes (ExpiredToken
,UnknownToken
,AlreadyAppliedToken
).
- e.g.
use Orisai\Exceptions\DomainException;
final class AccountBalanceTooLow extends DomainException
{
private Account $account;
private Money $neededAmount;
public static function create(Account $account, Money $neededAmount): self
{
$self = new self();
$self->account = $account;
$self->neededAmount = $neededAmount;
return $self;
}
public function getAccount(): Account
{
return $this->account;
}
public function getNeededAmount(): Money
{
return $this->neededAmount;
}
}
If you want to add message, code or previous exception to error you can use fluent interface.
Generic exceptions used for programming errors which should likely be fixed.
- All of them must implement interface
UncheckedException
.- You may also extend
LogicalException
which implementsUncheckedException
, extends\LogicException
, disables default constructor and usesConfigurableException
trait.
- You may also extend
- They should have at least error message.
- In perfectly written code they should never occur.
- Handling of unchecked exceptions should be done by an error handler, e.g. Tracy debugger.
- Only valid reason to catch them is to add some additional debug info. In this case new exception must be thrown
with original exception added as previous (
$new->withPrevious($previous)
).
- Only valid reason to catch them is to add some additional debug info. In this case new exception must be thrown
with original exception added as previous (
- They should not be part of the function signature.
- Unless the exception subclass covers a common use case, e.g. adds useful info via name or property or the exception has valid reason to be caught then an existing uncaught exception should be used.
We currently provide following unchecked exceptions:
LogicalException
- generic, base exception, must be extendedDeprecated
- method is no longer supported, implementation was removedInvalidArgument
- argument does not match with expected valueInvalidState
- method call is invalid for the object's current stateMemberInaccessible
- property or method is not accessible - not visible from calling scope nor by magic methodNotImplemented
- method is not implementedShouldNotHappen
- for cases which should never happen, but it's safer or easier to read with that "dead" branch of code
Programming errors (aka unchecked exceptions) should be as consistent and descriptive as possible. Message
helps you
with that by defining interface.
use Orisai\Exceptions\Logic\InvalidState;
use Orisai\Exceptions\Message;
$message = Message::create()
->withContext('Trying to commit an import.')
->withProblem('There is nothing to commit.')
->withSolution('Check that the import files are not empty, and that filters are not too restrictive.');
throw InvalidState::create()
->withMessage($message);
Message
casted to string looks like this:
Context: Trying to commit an import.
Problem: There is nothing to commit.
Solution: Check that the import files are not empty, and that filters are not
too restrictive.
- context, problem and solution are always in the same order
- only specified parts are rendered
Messages longer than 80 characters (including description) are formatted into multiple lines, except these messages which already contain newlines.
To change the default line length, use $lineLength
property, Message::$lineLength = 120;
.
Unique information above scope of Context-Problem-Solution can be added via with()
method.
use Orisai\Exceptions\Message;
Message::create()
->withContext('Message with custom fields.')
->with('Error hash', 'value');
Context: Message with custom fields.
Error hash: value
Aggregate multiple exceptions into one. Useful for handling unreliable subsystems whose crash should not stop processing by other subsystems.
Feature can be activated by using ConfigurableException
trait or any of the exceptions from this package.
use Orisai\Exceptions\Logic\ShouldNotHappen;
use Throwable;
$suppressed = [];
foreach ($this->runners as $runner) {
try {
$runner->execute($task);
} catch (Throwable $exception) {
$suppressed[] = $exception;
}
}
if ($suppressed !== []) {
throw ShouldNotHappen::create()
->withMessage('Some of the runners failed during task execution.')
->withSuppressed($suppressed);
}
Message of exception is an aggregation of its own and suppressed exceptions messages.
This behavior can be disabled by LogicalException::$addSuppressedToMessage = false;
and DomainException::$addSuppressedToMessage = false;
.
- Note: Property is defined by
ConfigurableException
trait but PHP just copy-pastes trait behavior into using classes, and so they have to be configured individually because property value is not shared. Since PHP 8.1 setting trait value directly has no effect, and before it set current value from trait when class was loaded, further modifications had no effect.
Some of the runners failed during task execution.
Suppressed errors:
- Error created at /path/to/FooRunner.php:38 with code 0
An error message
- Exception created at /path/to/BarRunner.php:97 with code 0
<NO MESSAGE>
- Orisai\Exceptions\Logic\InvalidState created at /path/to/BazRunner.php:51 with code 0
Problem: problem
Solution: solution
Suppressed exceptions can also be accessed via $exception->getSuppressed()
.
Why there is no Exception
suffix? InvalidState
instead of InvalidStateException
? There are several reasons:
- IDEs are smart. In catch statement and after throw should be hinted only classes which implement Throwable, typing
the
Exception
part should not be required - Suffix often lead us to write inaccurate exception names. While
ValidationException
could seem reasonable at first, what should we except from classValidation
? Exception name describes where is the problem but not what is the problem. It forces us to think about the name. What aboutInvalidData
? Now source of the problem is obvious which makes the suffix superfluous.
One of the main reasons why checked exceptions exist is they provide an easy way how to enforce user errors to be added into function signature.
final class HasToBeAnnotated extends \Orisai\Exceptions\DomainException
{
public function create(): self
{
return new self();
}
}
/**
* @throws HasToBeAnnotated
*/
function doSomething(): void
{
throw HasToBeAnnotated::create();
}
Enforcement is achieved via static analysis. Officially supported
are PHPStan exception rules but other tools may support that as well. Only requirement is to
configure thrown CheckedException
to be either caught or added into method signature.
This approach will not work in case code is not called directly and caller don't know which code will be executed. Usual cases where this happens are controller actions executed by router, events dispatched by event dispatcher or message handlers executed by message bus. In these cases the called code should never throw any exceptions unless they are part of the interface which is known by calling code.
We use PHPStan built-in exception rules to ensure all checked exceptions are properly handled.
Add following configuration to your phpstan.neon
:
parameters:
exceptions:
check:
missingCheckedExceptionInThrows: true
tooWideThrowType: true
checkedExceptionClasses:
- Orisai\Exceptions\Check\CheckedException