@SmaineDev
๐งจ Occurs during program execution, interrupts the normal execution flow
๐งฉ Can be triggered by the code itself, the system, or an external resource (DB, file, APIโฆ)
๐ป Can happen at any point in the code, often where it's least expected ๐ญ
๐ฅ Without proper handling, it causes the program to crash or behave unpredictably
@SmaineDev
๐ Since PHP 5 Object-oriented error handling
๐ก Introduction of the \Exception class, the try/catch/ finally mechanism
PHP 7๏ธโฃ unifies error handling further with \Throwable
๐ชExceptions are objects : they carry a message, a code, a stack traceโฆ
๐ธ๏ธ You can extend the base Exception class to create your own custom exceptions
๐ฃExceptions can be nested catch one, throw another with more context
๐Follows OOP principles : inheritance, polymorphism, and encapsulation apply to exceptions too
@SmaineDev
๐ฃ๏ธ Altering the workflow of a program
โ ๏ธ Without exceptions : an error can fail silently or crash the program abruptly
๐ฎโโ๏ธ With exceptions : you intercept the error and decide how to react
๐You can propagate the exception, log it, display a user-friendly message, or implement a fallback
๐ฆพMakes your program more robust & resilient
@SmaineDev
@SmaineDev
Base class for all users exceptions
Base class for all internal error
@SmaineDev
interface Throwable
|- Error implements Throwable
|- ArithmeticError extends Error
|- DivisionByZeroError extends ArithmeticError
|- AssertionError extends Error
|- ParseError extends Error
|- TypeError extends Error
|- ArgumentCountError extends TypeError
|- Exception implements Throwable
|- ClosedGeneratorException extends Exception
|- DOMException extends Exception
|- ErrorException extends Exception
|- IntlException extends Exception
|- LogicException extends Exception
|- BadFunctionCallException extends LogicException
|- BadMethodCallException extends BadFunctionCallException
|- DomainException extends LogicException
|- InvalidArgumentException extends LogicException
|- LengthException extends LogicException
|- OutOfRangeException extends LogicException
|- PharException extends Exception
|- ReflectionException extends Exception
|- RuntimeException extends Exception
|- OutOfBoundsException extends RuntimeException
|- OverflowException extends RuntimeException
|- PDOException extends RuntimeException
|- RangeException extends RuntimeException
|- UnderflowException extends RuntimeException
|- UnexpectedValueException extends RuntimeException@SmaineDev
<?php
interface Throwable extends Stringable
{
public function getMessage(): string;
/** @return int */
public function getCode();
public function getFile(): string;
public function getLine(): int;
public function getTrace(): array;
public function getPrevious(): ?Throwable;
public function getTraceAsString(): string;
}
class Exception implements Throwable
{
final private function __clone() {}
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null) {}
public function __wakeup() {}
final public function getMessage(): string {}
/** @return int */
final public function getCode() {}
final public function getFile(): string {}
final public function getLine(): int {}
final public function getTrace(): array {}
final public function getPrevious(): ?Throwable {}
final public function getTraceAsString(): string {}
public function __toString(): string {}
}
class Error implements Throwable
{
// ...
}@SmaineDev
@SmaineDev
@SmaineDev
๐ An error code is assigned to the exception
๐ง๐ฟโ๐ฆฏโโก๏ธThe exception travels up the call stack frame by frame
๐ Each function/method in the stack is exited immediately
โ๏ธ If no catch is found along the way, the exception reaches the top of the stack
@SmaineDev
๐ฃ A catch block intercepts the exception and gives you full access to it ($e)
๐ You can log it, transform it, or wrap it into a new exception
๐ช Re-throwing and sends it back up the stack
๐ A finally block is always executed
@SmaineDev
try { ... }
catch(e) { ... }
<?= code/>
finally { ... }
@SmaineDev
@SmaineDev
@SmaineDev
Application
Business Logic ๐
Persistence ๐พ
User Side ๐
Domain
Infrastructure
@SmaineDev
High-level layer ๐น๏ธ
Low-level layer ๐ฆ
Catch
Log ?
Handle Response
Catch
Log
Throw
@SmaineDev
<?php
namespace App\Infrastructure;
class StripeProvider
{
// Service to update a user in Stripe via API (Low-level layer)
public function updateUser($id)
{
try{
$response = $this->stripeHttpClient->put($id, $data);
} catch (\Exception $e) {
return false;
}
}
}
namespace App\Domain;
class UpdateAccountHandler
{
// Handler to update a user in Stripe via API (Low-level layer)
public function update(UserId $id, array $data)
{
// tambouille ๐ฒ
$result = $stripeProvider->updateUser($id, $data);
// tambouille ๐ฒ
return $result;
}
}<?php
namespace App\Application;
#[AsCommand(name: 'app:xls:update_user')]
class UpdateAccountCommand
{
// Custom Command Feature Update User acount from XLS (High level layer)
public function execute($id, $data)
{
$result = $this->updateAccountHandler->update($id, $data);
if(!$result) {
// ?
}
}
}
class UpdateAccountAction
{
#[Route('/users/{id}', name: 'user_update')]
public function __invoke($id, $data)
{
$result = $this->updateAccountHandler->update($id, $data);
if(!$result) {
// ?
}
return $this->render('account.html.twig');
}
}
<?php
class StripeProvider
{
public function updateUser($id)
{
try{
$response = $this->stripeHttpClient->put($id, $data);
} catch (\Exception $e) {
// Yolo Mode, No log, No traces ๐ณ๏ธ
return false; ๐ฑ
}
}
}
class UpdateAccountAction
{
#[Route('/users/{id}', name: 'user_update')]
public function __invoke($id, $data)
{
try {
$result = $this->updateAccountHandler->update($id, $data);
} catch (\Exception $e) {
// Already catched, no Exception ๐ญ๐ญ๐ญ
}
if(!$result) {
// ?
}
return $this->render('account.html.twig');
}
}
<?php
class StripeProvider
{
public function updateUser($id)
{
try{
$response = $this->httpClient->put($id, $data);
} catch (ClientException $e){
$statusCode = $e->getResponse()->getStatusCode();
if(400 === $statusCode) {
$this->logger('Validation errors . $e->message');
throw new ValidationException($e);
}
if(404 === $statusCode) {
$this->logger('User not found errors . $e->message');
throw new NotFoundHttpException($e);
}
} catch (\Exception $e) {
// ๐ก Add context to thanks yourself when you will debug
$this->logger('Unable to update User id: .$id');
// Rethrow to not silent and hide the problem
throw new $e;
}
}
}class UpdateAccountAction
{
public function __invoke($id, $data)
{
try {
$result = $this->updateAccountHandler->update($id, $data);
} catch (ValidationException | NotFoundHttpException $e){
$errors = "User cannot be update";
}
return $this->render('account.html.twig', ['errors' => $errors ?? null]);
}
}
class UpdateAccountCommand
{
public function __invoke($id, $data)
{
try {
$this->updateAccountHandler->update($id, $data);
// ...
$io->success('blabla');
return Command::SUCCESS;
} catch (ValidationException | NotFoundHttpException $e) {
$io->error('User cannot be update');
return Command::FAILURE;
}
}
}class UpdateAccountAction
{
public function __invoke($id, $data)
{
try {
$result = $this->updateAccountHandler->update($id, $data);
} catch (ValidationException | NotFoundHttpException $e){
$errors = "User cannot be update";
}
return $this->render('account.html.twig', ['errors' => $errors ?? null]);
}
}
class UpdateAccountCommand
{
public function __invoke($id, $data)
{
try {
$this->updateAccountHandler->update($id, $data);
// ...
$io->success('blabla');
return Command::SUCCESS;
} catch (ValidationException | NotFoundHttpException $e) {
$io->error('User cannot be update');
return Command::FAILURE;
}
}
}<?php
class UpdateAccountCommand
{
public function __invoke(string $csvFilePath)
{
$rows = $this->parseCsv($csvFilePath);
/** @var Row $row */
foreach ($rows as $row) {
try {
$this->updateAccountHandler->update($id, $data);
$io->success("User {$id} updated successfully");
} catch (ValidationException | NotFoundHttpException $e) {
$io->error("User {$row->id} failed: " . $e->getMessage());
// Skip Exception.
} catch (FileException $e) {
$io->error('Unable to process the file');
// File corrupt, fail fast.
return Command::FAILURE;
}
}
$io->success('CSV import finished');
return Command::SUCCESS;
}
}<?php
class UpdateAccountCommand
{
public function __invoke(string $csvFilePath)
{
$rows = $this->parseCsv($csvFilePath);
/** @var Row $row */
foreach ($rows as $row) {
try {
$this->updateAccountHandler->update($id, $data);
$io->success("User {$id} updated successfully");
} catch (ValidationException | NotFoundHttpException $e) {
$io->error("User {$row->id} failed: " . $e->getMessage());
// Skip Exception.
} catch (FileException $e) {
$io->error('Unable to process the file');
// File corrupt, fail fast.
return Command::FAILURE;
}
}
$io->success('CSV import finished');
return Command::SUCCESS;
}
}Exception too broadly๐ You need to handle a specific scenario
๐ You need to give your error an "identity"
๐ You need to add extra information to the exception
๐ฅญ You need to catch it specifically without catching unrelated exceptions
๐ You need to make your code self-documenting
@SmaineDev
<?php
// โ Bad
throw new Exception("User not found");
throw new Exception("Invalid email");
// โ
Good โ custom exception
throw new UserNotFoundException("User {$id} not found");
throw new InvalidEmailException("Invalid email: {$email}");
// โ
Even better โ static named constructors
throw UserNotFoundException::byId($id);
throw UserNotFoundException::byName($name);
throw InvalidEmailException::alreadyExists($email);@SmaineDev
<?php
class UserNotFoundException extends RuntimeException
{
public static function byId(int $id): self
{
return new self("User with id '{$id}' was not found");
}
public static function byName(string $name): self
{
return new self("User with name '{$name}' was not found");
}
}
class InvalidEmailException extends RuntimeException
{
public static function alreadyExists(string $email): self
{
return new self("Email '{$email}' is already taken");
}
public static function invalidFormat(string $email): self
{
return new self("Email '{$email}' has an invalid format");
}
}๐ Don't catch to return null/false in low-level layer
๐ฃ Catch at the right time, you favorite framework handles well the \Exception
๐ Use Explicit message and Explicit naming
๐ชThrow good \Exceptions (RuntimeException...)
โ ๏ธ An \Exception is caught once
โ Test the \Exceptions and the worst case scenario
๐กAvoid Generic \Exception and use Custom/Domain exception
@SmaineDev
๐๐ผ Listen kernel.exception Event via an Event Subscriber or Event Listener (attribute #[AsEventListener])
๐ซ Fired automatically when an unhandled exception reaches the kernel
๐ Gives you access to the full exception object + the original request
๐ฉ You can return a custom Response and stop propagation
โ๏ธ Symfony's own ErrorListener listens too, mind your priority to run before it
@SmaineDev
use Psr\Log\LogLevel;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
use Symfony\Component\HttpKernel\Attribute\WithLogLevel;
#[WithHttpStatus(409)]
class OutOfStockException extends \DomainException {}
#[WithHttpStatus(429, headers: ['Retry-After' => 60])]
class RateLimitExceededException extends \RuntimeException {}
#[WithLogLevel(LogLevel::WARNING)]
class PaymentFailedException extends \RuntimeException {}๐ Your Domain exceptions declare their own status & severity, no listener, no mapping (Symfony 6.3+)
@SmaineDev
๐จ Override error/*.html.twig templates per HTTP status code (error/404.html.twig, error/500.html.twig)
โ๏ธ Use a custom ErrorController to fully control the response format (json, html, csvโฆ)
๐ฅ Decorate the ProblemNormalizer to reshape the JSON error payload (RFC 7807)
@SmaineDev
MonologBundle FTW๐ฅ๐๏ธ Exceptions are automatically logged at the right severity level
๐๏ธ Tune the severity per exception with #[WithLogLevel]
๐ช Configurable channels (app, payment, apiโฆ) to separate concerns
๐ฉ Send logs to files, Slack, Sentry, Datadogโฆ via handlers
๐ Add context via Processors
@SmaineDev
๐ซ No HTTP response in a worker : the exception drives the retry
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2 # 1s, 2s, 4sโฆ exponential backoff// don't retry what can never succeed ๐งฑ
throw new UnrecoverableMessageHandlingException('Order already refunded');๐ Transient error (timeout, deadlockโฆ)? Retry with backoff
โฐ๏ธ Permanent error? Fail fast => straight to the failed transport
@SmaineDev
Your API speaks HTTPโฆ your \Exceptions should too ๐ก
@SmaineDev
๐จUncaught \Exception is turned into a structured HTTP error response
๐ฃ๏ธContent negotiation: errors follow the requested format (hydra/json-ld..)
๐งฑSince API Platform 3.2 : errors are ApiResources themselves!
๐RFC 7807 Problem Details (application/problem+json) by default
# config/packages/api_platform.yaml
api_platform:
defaults:
rfc_7807_compliant_errors: true
exception_to_status:
# built-in exceptions
Symfony\Component\Serializer\Exception\ExceptionInterface: 400
ApiPlatform\Exception\InvalidArgumentException: 400
# your Domain exceptions ๐
App\Exception\ProductNotFoundException: 404
App\Exception\OutOfStockException: 409๐ช Throw your Domain \Exception, API Platform picks the right status code
@SmaineDev
#[ApiResource(
exceptionToStatus: [
ProductNotFoundException::class => 404,
OutOfStockException::class => 409,
],
operations: [
new Post(
processor: PlaceOrderProcessor::class,
// operation level wins over resource level ๐ฅ
exceptionToStatus: [
PaymentFailedException::class => 402,
],
),
],
)]
class Order {}๐๏ธ Precedence : Operation > Resource > Global config
@SmaineDev
โ๏ธ ValidationException โ 422 Unprocessable Entity, automatically
๐งพ One entry per violation : perfect for form mapping on the client side
@SmaineDev
RFC 7807 (Problem Details)
API Platform's additions based on SF validator
use ApiPlatform\Metadata\ErrorResource;
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
#[ErrorResource]
class OutOfStockError extends \Exception implements ProblemExceptionInterface
{
public function getType(): string { return '/errors/out-of-stock'; }
public function getTitle(): ?string { return 'Out of stock'; }
public function getStatus(): ?int { return 409; }
public function getDetail(): ?string { return $this->getMessage(); }
public function getInstance(): ?string { return null; }
}
// in your Provider / Processor
throw new OutOfStockError('Product 42 is out of stock ๐ข');๐ช The \Exception is the API resource : full control over the error payload
@SmaineDev
use ApiPlatform\State\ApiResource\Error;
#[AsAlias('api_platform.state.error_provider')]
#[AsTaggedItem('api_platform.state.error_provider')]
final class ErrorProvider implements ProviderInterface
{
public function provide(
Operation $operation,
array $uriVariables = [],
array $context = []): object
{
$exception = $context['request']->attributes->get('exception');
$status = $operation->getStatus() ?? 500;
$error = Error::createFromException($exception, $status);
if ($status >= 500) {
$error->setDetail('Something went wrong');
}
return $error;
}
}๐งฐ One place to translate, enrich or redact every error of your API
@SmaineDev
๐ฏ Throw Domain \Exceptions, map them with exceptionToStatus
๐คซ Never leak internals : hide messages & stack traces of 5xx
๐งพ Let the Validator produce the 422s, don't reinvent it
๐ Keep Monolog/Sentry in the loop : kernel.exception still fires
โ
Test status codes and error payloads with ApiTestCase
๐ Document your errors in OpenAPI so consumers know what to expect
@SmaineDev