Engineering Manager

๐Ÿ‘‹๐Ÿฝ Smaรฏne Milianni

      @SmaineDev

      smaine-milianni.medium

      ismail1432

      ismail1432/conferences

โœ๐Ÿฟ

 

๐Ÿฟ

Futur Responsable Com' de la conf

@SmaineDev

An \Exception?

๐Ÿงจ 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

The PHP Way ๐Ÿ˜

@SmaineDev

Don't Let Errors

Run the Show ๐ŸŽฌ

๐Ÿ›ฃ๏ธ 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

  ๐Ÿ–๏ธ A Family Story

@SmaineDev

\Exception

Base class for all users exceptions

\Error

Base class for all internal error

interface \Throwable

@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

Everything fails all the time๐Ÿ”ฅ

@SmaineDev

@SmaineDev

๐Ÿ”‰ The Bubbling Effect

๐Ÿƒ 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

Catch, Inspect,  Re-throw

๐ŸŽฃ 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 { ... }

 

Workflow

@SmaineDev

What Should I Do

if I met an \Exception?

@SmaineDev

Log

Throw

Catch

@SmaineDev

Application

Business Logic ๐Ÿ“–

Persistence ๐Ÿ’พ

Application - Domain - Infrastructure

User Side ๐Ÿ™‹

Domain

Infrastructure

@SmaineDev

High-level layer ๐Ÿ•น๏ธ

Application - Domain - Infrastructure

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; 
        }
    }
 }

Improvements ๐Ÿ’…

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;
  }
}

๐Ÿค• Common Mistakes

Catching Exception too broadly

Swallowing the exception silently

Using exceptions for normal flow control

Not logging / losing the stack trace

Throwing a generic exception instead of a domain exception

Re-throwing for no reason

Generic 

or

Custom

The \Exception that proves the Rules

Custom \Exception use cases

๐Ÿˆ 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");
    }
}

Senzu Bean ๐Ÿซ˜

๐Ÿ›‘ 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

Intercepting Exceptions in Symfony

๐Ÿ‘‚๐Ÿผ 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

๐Ÿท๏ธ #[WithHttpStatus] & #[WithLogLevel]

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

 ๐Ÿ”„ Transforming Exceptions in Symfony

๐ŸŽจ 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

๐Ÿ“– Logging Exceptions in Symfony

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

๐Ÿ“ฌ And in Async? Exceptions in Messenger

๐Ÿšซ 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

The API Platform Way 

Your API speaks HTTPโ€ฆ your \Exceptions should too ๐Ÿ“ก

@SmaineDev

๐Ÿ‘ป Errors Out of the Box ๐ŸŽ

๐Ÿšจ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

Map \Exceptions

to Status Codes

# 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

๐ŸŽฏ โ€ฆor per Resource & Operation

#[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

Validation Already Handled โœ…

โš™๏ธ 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

๐Ÿ’Ž Your Own Error Resource

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

๐ŸŽ›๏ธ Customize the Error Provider

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

๐Ÿ† Good Practices in API Platform

๐ŸŽฏ 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

Any Question before I Quit?