CheckedExceptions
The missing native PHP feature
Jan Nedbal
Principal Engineer, Head of DX
Sli.do
sli.do/php-more
Handling erroneous state
/**
* @template Ok, Err
*/
readonly class ResultObject {
public __construct (
public Ok $ok;
public Err $error;
) {}
}
try {
$this->stuff();
} catch (StuffBroken $e) {
$this->logger->exception($e);
}
Exception types
Exception types in PHP
Checked vs Unchecked basic guidelines
Exceptions hierarchy
\Exception
\RuntimeException
\LogicException
(final) \App\LogicException
(abstract) \App\RuntimeException
\App\CannotEditLockedProductException
\App\PackagingCalculationFailedException
Checked
Unchecked
Native
huge flat structure
}
CheckedException example
/**
* @throws AccountNotFoundException
* @throws CannotCloseAccountWithProductOnStockException
*/
public function closeAccount(int $accountId): void
{
$account = $this->accountRepository->get($accountId);
if ($account->hasAnyProductOnStock()) {
throw new CannotCloseAccountWithProductOnStockException();
}
$account->close();
}
UncheckedException example
enum FooStatus: string
{
case InProgress = 'in_progress';
case Completed = 'completed';
public function getLabel(): string
{
return match ($this) {
self::InProgress => 'In Progress',
self::Completed => 'Completed',
default =>
throw new LogicException('New enum case added, please implement getLabel'),
};
}
}
Exception types decisions
PHPStan exception analysis
parameters:
featureToggles:
detectDeadTypeInMultiCatch: true # or enable bleedingEdge
exceptions:
check:
missingCheckedExceptionInThrows: true # enforce checked ones in @throws
tooWideThrowType: true # report dead @throws (exception never thrown)
implicitThrows: false # no @throws = nothing is thrown (otherwise Throwable is)
checkedExceptionClasses:
- YourApp\RuntimeException # track your exceptions
- GuzzleHttp\Exception\GuzzleException # track libs you verified
strict
exceptions
config
since May 2021
PHPStan exception analysis example #1
class ProductController
{
#[Get('/product/{id}')]
public function get(Uuid $id): ProductOutput
{
$this->checkAcl($id);
// ...
}
private function checkAcl(Uuid $id): void
{
if (!$this->canAccessProduct($id)) {
throw new CannotAccessForeignProductException(); // error: missing in @throws
}
}
}
PHPStan exception analysis example #2
class ProductController
{
#[Get('/product/{id}')]
public function get(Uuid $id): ProductOutput
{
$this->checkAcl($id); // error: missing in @throws
// ...
}
/** @throws CannotAccessForeignProductException */
private function checkAcl(Uuid $id): void
{
if (!$this->canAccessProduct($id)) {
throw new CannotAccessForeignProductException();
}
}
}
PHPStan exception analysis example #3
class ProductController
{
/** @throws CannotAccessForeignProductException */ // PHPStan is happy, but Symfony not!
#[Get('/product/{id}')]
public function get(Uuid $id): ProductOutput
{
$this->checkAcl($id);
// ...
}
/** @throws CannotAccessForeignProductException */
private function checkAcl(Uuid $id): void
{
if (!$this->canAccessProduct($id)) {
throw new CannotAccessForeignProductException();
}
}
}
PHPStan exception analysis example #4
class ProductController
{
#[Get('/product/{id}')]
public function get(Uuid $id): ProductOutput
{
try {
$this->checkAcl($id);
} catch (AclException $e) { // catching supertype is allowed, but don't do it
throw new BadRequestException('Access denied', $e);
}
}
/** @throws CannotAccessForeignProductException */
private function checkAcl(Uuid $id): void
{
if (!$this->canAccessProduct($id)) {
throw new CannotAccessForeignProductException();
}
}
}
PHPStan exception analysis example #5
class ProductController
{
#[Get('/product/{id}')]
public function get(Uuid $id): ProductOutput
{
try {
$this->checkAcl($id);
} catch (CannotAccessForeignProductException $e) {
throw new BadRequestException('Cannot access foreign product', $e);
}
}
/** @throws CannotAccessForeignProductException */
private function checkAcl(Uuid $id): void
{
if (!$this->canAccessProduct($id)) {
throw new CannotAccessForeignProductException();
}
}
}
@throws & catch guidelines
Recategorization use-cases
public function closeAccount(Account $account): void
{
$account->deactivateAllUsers();
try {
$account->close();
} catch (CannotCloseAccountWithActiveUserException $e) {
throw new LogicException('All users were deactivated above', $e); // now it is logic error
}
}
PHPStan issue #1 - Immediately called callback
/**
* @throws CannotCloseAccountWithProductOnStockException // reported as dead catch
*/
public function closeAccount(int $accountId): void
{
$this->entityManager->transactional(function () use ($accountId): void {
$account = $this->accountRepository->get($accountId);
$account->close();
});
}
$accounts = array_map(
fn (int $productId) => $this->accountRepository->get($accountId), // exception lost
$accountIds
);
PHPStan issue #2 - Not immediately called callback
$closeAccountCallback = function (int $accountId): void {
$account = $this->accountRepository->get($accountId);
$account->close();
};
$closeAccountCallback(1); // no checked exception is tracked here
PHPStan issue #3 - Generators
class CsvParser
{
/** @throws CsvFileNotReadableException */
public static function parse(string $file): iterable
{
$handle = fopen($file, 'r');
if ($handle === false) throw new CsvFileNotReadableException();
while (true) {
$row = fgetcsv($handle);
if ($row === false) break;
yield $row;
}
}
}
CsvParser::parse($path); // this will NEVER throw exception, but PHPStan thinks it will
PHPStan issue #4 - Iterators
class GitLabMergeRequestsIterator implements Iterator
{
/**
* @throws GitLabApiNotAvailableException
*/
public function next(): void
{
$this->current = $this->loadNextMergeRequest()
}
}
foreach ($iterator as $item) { // thrown exception not detected
}
PHPStan issue #5 - Global scope
$csvParser->parse(STDIN); // no PHPStan report of thrown exception
PHPStan issue #6 - LSP not checked
interface Calculator
{
/** @throws SomeException */
public function calculate();
}
class SpecificCalculator implements Calculator
{
/** @throws AnotherException */ // incompatible with parent
public function calculate();
}
PHPStan issues solution
composer require --dev
shipmonk/phpstan-rules
}
inline /** @throws */
}
Send PR :)
}
(Non-)Immediately called closure solution
parameters:
shipmonkRules:
forbidCheckedExceptionInCallable:
immediatelyCalledCallables:
'Doctrine\ORM\EntityManager::transactional': 0
'ShipMonk\Datadog\TracingService::traceCallback': 1
/**
* @throws CannotCloseAccountWithProductOnStockException
*/
public function closeAccount(int $accountId): void
{
$this->entityManager->transactional(function () use ($accountId): void {
$account = $this->accountRepository->get($accountId);
$account->close();
});
}
Other notes
Sources
Thank you!
Questions?