1 of 30

CheckedExceptions

The missing native PHP feature

2 of 30

Jan Nedbal

Principal Engineer, Head of DX

3 of 30

Sli.do

sli.do/php-more

4 of 30

Handling erroneous state

  • Return value
    • null, false, union, ResultObject, …
    • Manual propagation needed
    • Handling of error value not enforced
      • With PHPStan, Foo|null partially is

  • Exception
    • Automatic propagation, but impossible to track unless automated
    • Handling of error state enforced

/**

* @template Ok, Err

*/

readonly class ResultObject {

public __construct (

public Ok $ok;

public Err $error;

) {}

}

try {

$this->stuff();

} catch (StuffBroken $e) {

$this->logger->exception($e);

}

5 of 30

Exception types

  • Representing "Expected exceptional state"
    • You want caller to be forced to handle those
    • e.g. business violation, HTTP failure, …
    • Present in catch or @throws
    • = CheckedExceptions

  • Representing "Unexpected state"
    • Programmer's fault when thrown
    • e.g. system failures, safety-checks (similar to native assert)
    • Never in catch (should crash your app)
    • = UncheckedExceptions

6 of 30

Exception types in PHP

  • "Expected exceptional state"
    • HTTP failure
    • PHP call it RuntimeException
    • = CheckedExceptions

  • "Unexpected state"
    • Programmer's fault
    • PHP call it LogicException
    • = UncheckedExceptions

  • Used to Java naming? Suffer silently.
    • RuntimeException is unchecked there

7 of 30

Checked vs Unchecked basic guidelines

  • CheckedExceptions
    • Class name matters (readability of catch statements)
    • Error message does not really matter

  • UncheckedExceptions
    • Class name does not matter (never caught)
    • Error message matters (help programmer fix the issue)

8 of 30

Exceptions hierarchy

\Exception

\RuntimeException

\LogicException

(final) \App\LogicException

(abstract) \App\RuntimeException

\App\CannotEditLockedProductException

\App\PackagingCalculationFailedException

Checked

Unchecked

Native

huge flat structure

}

9 of 30

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();

}

10 of 30

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'),

};

}

}

11 of 30

Exception types decisions

  • What is (un)expected?
    • You decide what should be handled
    • Depends on context
      • apps typically wont handle database failures
      • DBAL-like library will handle database failures

12 of 30

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

  • CheckedExceptions
    • Usage in catch / @throws can be enforced
  • UncheckedExceptions
    • Usage in catch / @throws not denied

strict

exceptions

config

since May 2021

13 of 30

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

}

}

}

14 of 30

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();

}

}

}

15 of 30

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();

}

}

}

16 of 30

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();

}

}

}

17 of 30

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();

}

}

}

18 of 30

@throws & catch guidelines

  • No @throws within any framework entrypoint
    • Controller's action
    • Command's execute
    • Listener's handler
  • No exceptions from outer module / namespace / lib
    • e.g. GuzzleException - catch it asap and wrap to exception with meaningful name
  • Use only checked exceptions in @throws
    • otherwise, it is not tracked
  • (almost) Never catch \Throwable or \Exception
    • you dont know what are you handling (it can be TypeError)
    • those don't pop-up in tests
  • Use final exceptions
    • or do not put interfaces in @throws

19 of 30

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

}

}

  • Some business logic may cause CheckedException to become "should never happen"
    • Wrap it to LogicException + pass as previous
    • Same pattern can be used across modules

20 of 30

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

);

21 of 30

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

22 of 30

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

23 of 30

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

}

24 of 30

PHPStan issue #5 - Global scope

$csvParser->parse(STDIN); // no PHPStan report of thrown exception

25 of 30

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();

}

26 of 30

PHPStan issues solution

  • #1 - Immediately called callback
  • #2 - Not immediately called callback
  • #3 - Generators
  • #4 - Iterators
  • #5 - Global scope
  • #6 - LSP

composer require --dev

shipmonk/phpstan-rules

}

inline /** @throws */

}

Send PR :)

}

27 of 30

(Non-)Immediately called closure solution

parameters:

shipmonkRules:

forbidCheckedExceptionInCallable:

immediatelyCalledCallables:

'Doctrine\ORM\EntityManager::transactional': 0

'ShipMonk\Datadog\TracingService::traceCallback': 1

  • Configure which closures are immediately called, others cannot throw

/**

* @throws CannotCloseAccountWithProductOnStockException

*/

public function closeAccount(int $accountId): void

{

$this->entityManager->transactional(function () use ($accountId): void {

$account = $this->accountRepository->get($accountId);

$account->close();

});

}

28 of 30

Other notes

  • New CheckedException thrown in low level
    • Propagation is painful
    • PHPStorm's "Call hierarchy" is helpful

  • /** @throws void */
    • Override parent's @throws

  • pepakriz/phpstan-exception-rules
    • The "old solution"

29 of 30

  1. https://phpstan.org/blog/bring-your-exceptions-under-control
  2. https://github.com/shipmonk-rnd/phpstan-rules
  3. https://github.com/pepakriz/phpstan-exception-rules

Sources

30 of 30

Thank you!

Questions?