Gestion d’erreur PHP : bonnes pratiques et configuration développement/production

Table des matières : Comprendre ce qui “casse” en PHP 8.x (et pourquoi PrestaShop ne réagit pas toujours comme vous l’imaginez) En PHP 8.1–8.5 (le spectre visé par PrestaShop 9.1), il faut distinguer trois familles qui n’ont pas les mêmes conséquences opérationnelles : les erreurs PHP (EERROR, EWARNING, E_NOTICE…), les exceptions (Exception, RuntimeException…) et les…

Écran avec du code et graphiques dans un environnement de développement.

Table des matières :

  1. Comprendre ce qui “casse” en PHP 8.x (et pourquoi PrestaShop ne réagit pas toujours comme vous l’imaginez)
  2. Configuration PHP dev/prod : les réglages qui changent vraiment la donne (FPM, Apache, CLI)
  3. Côté PrestaShop 9.x : mode debug, cache Symfony, et limites du “cœur”
  4. Logging “propre” : PSR-3, Monolog, et pourquoi les journaux en base ne suffisent pas
  5. Patterns de gestion d’exception dans les modules : attraper moins, enrichir plus
  6. Dev vs prod : convertir les warnings en exceptions en CI, et déployer sans se tirer une balle dans le pied

Comprendre ce qui “casse” en PHP 8.x (et pourquoi PrestaShop ne réagit pas toujours comme vous l’imaginez)

En PHP 8.1–8.5 (le spectre visé par PrestaShop 9.1), il faut distinguer trois familles qui n’ont pas les mêmes conséquences opérationnelles : les erreurs PHP (EERROR, EWARNING, E_NOTICE…), les exceptions (Exception, RuntimeException…) et les “Errors” du moteur (TypeError, ParseError…) qui héritent de Throwable. Depuis PHP 7, beaucoup de fatalités historiques sont remontées sous forme d’objets Error attrapables, mais pas toutes (un crash mémoire, certains segfaults, etc.). En production, votre objectif n’est pas “zéro erreur”, c’est : aucun affichage côté utilisateur + un signal exploitable côté ops/dev.

Pour clarifier rapidement l’impact “terrain” (utile quand vous dépannez un shop à chaud) :

Catégorie Exemple typique Peut être catch ? Effet le plus fréquent en prod
Erreur PHP (warning/notice/deprecated) appel à une fonction avec signature dépréciée Non (nativement) bruit dans les journaux, parfois casse logique (données invalides)
Exception throw new \RuntimeException() Oui page d’erreur gérée (si framework/handler) ou incident contrôlé
Error moteur (TypeError, ParseError) mauvais type passé à une méthode typée Oui (via Throwable) fatal “propre” si intercepté, sinon 500/white screen

Deux points qui surprennent souvent lors d’une migration PrestaShop (1.7/8 → 9) :

  • Les warnings “inoffensifs” deviennent bloquants quand une dépendance ou un module passe à des signatures plus strictes (types scalaires, readonly, appels de méthodes dont la signature a changé, etc.). Le symptôme typique : “ça marchait en staging”, puis un flux réel (import, webservice, cron) échoue car il déclenche un chemin de code rarement exécuté.
  • Les dépréciations (E_DEPRECATED) ne sont pas juste “du bruit” : elles sont souvent le pré-signal d’un futur comportement supprimé. Les ignorer sur plusieurs mois, c’est s’exposer à un saut de version où l’erreur “change de classe” (warning → fatal, ou comportement silencieux → rupture).

Dans un projet PrestaShop moderne, le piège classique est de croire qu’un réglage global suffit. PrestaShop 9.x est encore un hybride : une partie “legacy” (contrôleurs front/BO historiques, overrides, hooks) et une partie Symfony. Selon le point d’entrée, l’erreur peut être interceptée par le kernel Symfony (avec ses pages de debug en dev), par un handler legacy, ou remonter directement au serveur (502/white screen) si elle survient trop tôt (autoload, bootstrap, cache corrompu, etc.). Résultat : deux comportements pour une même erreur selon que vous êtes dans un ModuleFrontController legacy ou dans un contrôleur Symfony.

Mini-scénario très concret (vu en e-commerce) : un module ajoute un hook actionValidateOrder. En HTTP “classique”, l’erreur est capturée et journalisée, et le client voit juste un paiement “échoué”. Mais le même code, exécuté dans un cron CLI (rattrapage de commandes en échec), peut se comporter autrement (pas de session, shop non déterminé, timezone/locale différente), et vous vous retrouvez avec un script qui s’arrête net sans sortie utile si le logging CLI n’est pas correctement configuré.

Enfin, ne sous-estimez pas la dette technique “silencieuse” : des E_DEPRECATED ou E_WARNING ignorés en prod deviennent des crashs lors d’un saut PHP (ex. durcissements de typage, dépréciations supprimées, changements de signature). C’est exactement le genre de problème qu’on voit lors des migrations (PrestaShop 1.7/8 → 9) : vous “passez” en staging, puis vous tombez sur des fatal errors sur un flux réel (cron, import, webservice). Si vous êtes en phase de montée de version, gardez une lecture : PrestaShop 9 : guide de migration depuis 1.7 et 8 avec 1-Click Upgrade.

Configuration PHP dev/prod : les réglages qui changent vraiment la donne (FPM, Apache, CLI)

Le socle, c’est error_reporting, display_errors et log_errors. En environnement de développement, vous voulez remonter tout ce qui est actionnable (E_ALL) et afficher (ou au minimum journaliser) immédiatement. En production, vous voulez toujours journaliser, jamais afficher. La doc officielle est explicite sur display_errors : “This is a feature to support your development and should never be used on production systems (e.g. systems connected to the internet).” (PHP manual, directive display_errors) : https://www.php.net/manual/en/errorfunc.configuration.php#ini.display-errors

Un point souvent raté : les réglages diffèrent selon le SAPI (PHP-FPM, Apache mod_php, CLI). Avec PrestaShop 9.1 et son usage plus fréquent du CLI (commandes Symfony, scripts d’admin, jobs), vous devez vérifier FPM + CLI. La page phpinfo() du BO ne reflète pas forcément le php.ini de php en ligne de commande. Pour diagnostiquer :

php --ini
php -i | grep -E "Loaded Configuration|error_reporting|display_errors|log_errors|error_log"

Exemple de base “dev” (PHP 8.2/8.3, applicable 8.1–8.5) :

; DEV
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
html_errors = On
; évitez de spammer les journaux en dev si besoin
ignore_repeated_errors = Off
zend.exception_ignore_args = Off

Exemple “prod” minimaliste (le bon sens, pas du dogme) :

; PROD
error_reporting = E_ALL
display_errors = Off
display_startup_errors = Off
log_errors = On
; idéalement un fichier dédié par pool FPM / vhost
error_log = /var/log/php/php-fpm-error.log
html_errors = Off
zend.exception_ignore_args = On  ; évite de leak des arguments (tokens, mots de passe)

Ce que ces réglages changent réellement (et ce qu’ils ne changent pas)

  • error_reporting=E_ALL en prod n’est pas contradictoire avec “stabilité” : ce réglage dit “quels événements sont générés”, pas “où ils sont affichés”. Le point critique en production est l’affichage (display_errors=Off) et la collecte fiable (log_errors=On + destination maîtrisée).
  • display_startup_errors est utile en dev parce que certaines erreurs arrivent avant le chargement complet (extensions, preload, etc.). En prod, laissez-le à Off : ce sont précisément les erreurs qui peuvent exposer des chemins sensibles.

Attention au paramétrage “réel” côté FPM

Sur beaucoup de serveurs, php.ini n’est pas la dernière autorité : un pool FPM peut forcer des valeurs via php_admin_value / php_admin_flag. C’est un bon outil… à condition de savoir qu’il existe.

Exemple typique (pool FPM) :

; /etc/php/8.2/fpm/pool.d/www.conf (exemple)
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off
php_admin_value[error_log] = /var/log/php/php-fpm-www-error.log

Si vous dépannez un “mystère” (rien dans les journaux applicatifs, 502 côté Nginx), vérifier le couple journaux PHP-FPM + journaux Nginx/Apache est souvent plus payant que de modifier du code. En Nginx, un 502 Bad Gateway peut simplement refléter un PHP-FPM qui a redémarré après un fatal, et l’explication est… dans le log du pool.

Deux risques concrets à gérer côté serveur : (1) permissions sur error_log (sinon vous perdez les erreurs et vous ne le saurez pas) ; (2) rotation (logrotate/journald), sinon vous remplissez le disque et vous créez un incident plus violent que l’erreur initiale. En hébergement mutualisé, error_log peut être imposé : adaptez-vous, mais gardez le principe journaliser sans afficher.

Checklist rapide (utile avant une mise en prod PrestaShop) :

  • php --ini (CLI) pointe-t-il vers le même “monde” que le FPM ?
  • Le fichier error_log existe-t-il et est-il écrivable par l’utilisateur FPM ?
  • Un mécanisme de rotation est-il actif (logrotate, journald, plateforme) ?
  • Vos erreurs PHP finissent-elles dans un endroit consulté (centralisation, backup, monitoring) ?

Côté PrestaShop 9.x : mode debug, cache Symfony, et limites du “cœur”

PrestaShop expose encore _PS_MODE_DEV_ (typiquement défini dans config/defines.inc.php), qui influe sur l’affichage des erreurs côté legacy et sur certains comportements (compilation Smarty, cache, etc.). Dans PrestaShop 9.1, on a en parallèle la mécanique Symfony (environnements dev/prod, cache dans var/cache/). Le point important : activer le debug PrestaShop n’est pas équivalent à activer display_errors PHP, et l’un ne remplace pas l’autre. Vous pouvez parfaitement avoir _PS_MODE_DEV_ = false et pourtant afficher des warnings si display_errors=On au niveau PHP.

Deux “effets de bord” utiles à connaître quand vous alternez debug et prod :

  • Cache Symfony : une exception “fantôme” peut venir d’un cache corrompu ou non cohérent avec les fichiers déployés (par exemple, un déploiement qui remplace des fichiers sans purger correctement var/cache/prod). Dans ce cas, vous pouvez perdre un temps fou à chercher “le bug” alors que le code n’est pas celui qui s’exécute réellement.
  • Legacy vs Symfony : selon le controller, vous n’avez pas la même page d’erreur, ni les mêmes journaux, ni la même granularité de contexte.

Pour des déploiements propres, évitez la bidouille “je passe en dev sur le serveur prod” : vous révélez stack traces, chemins de fichiers, versions, parfois des fragments de configuration. Préférez : (a) un staging proche prod, (b) des journaux centralisés, (c) une reproduction locale avec des dumps de DB anonymisés. Si vous devez basculer un environnement Symfony (APPENV/APPDEBUG), faites-le via variables d’environnement au niveau du service (systemd, Docker, plateforme PaaS), pas en modifiant des fichiers trackés.

Un garde-fou simple (surtout en Europe où les journaux peuvent contenir des données personnelles si vous n’êtes pas strict) : gardez les détails techniques dans les journaux, et limitez côté utilisateur à un message générique + identifiant d’incident (timestamp, request-id, ou code). Cela accélère le support sans augmenter la surface d’exposition.

Le cœur PrestaShop a aussi ses angles morts : le legacy journalise encore beaucoup via PrestaShopLogger (souvent en base) et ne structure pas naturellement le contexte (shop, employee, cart, order, request id). Ça reste utile pour un back-office, mais pour l’observabilité sérieuse (corrélation, volumétrie, alerting), vous aurez intérêt à passer par un logger PSR-3 (Monolog) et une pipeline dédiée. À ce sujet, si vous exploitez davantage les commandes CLI en 9.1 (crons, maintenance), le contexte d’erreur change : pas de session BO, pas d’employee, parfois pas de shop déterminé. Référence utile : PrestaShop 9.1 : compatibilité PHP 8.1–8.5, CLI et nouveautés développeurs.

Logging “propre” : PSR-3, Monolog, et pourquoi les journaux en base ne suffisent pas

Si vous développez des modules, arrêtez de “logger à la main” avec des file_put_contents() dans un coin du serveur : vous perdez la rotation, les niveaux, le contexte et la compatibilité infra. Le standard minimal côté PHP est PSR-3 (Psr\Log\LoggerInterface). La spec impose notamment : “The message MUST be a string or object implementing __toString().” (PSR-3, PHP-FIG) : https://www.php-fig.org/psr/psr-3/

Dans un contexte Symfony (donc PrestaShop 9.x), Monolog est la brique la plus courante derrière PSR-3. L’intérêt n’est pas “d’avoir un log”, c’est d’avoir des channels, des handlers (fichier, syslog, HTTP), du format (JSON), et des processors (ajout automatique de contexte). Sur un shop e-commerce, le contexte utile est très concret : shop_id, employee_id, customer_id, cart_id, order_id, module, hook, controller, trace_id.

Exemple de configuration (principe) pour écrire du JSON en prod, plus simple à ingérer dans ELK/OpenSearch/Datadog :

# config/packages/prod/monolog.yaml
monolog:
  handlers:
    main:
      type: stream
      path: "%kernel.logs_dir%/%kernel.environment%.json.log"
      level: error
      formatter: monolog.formatter.json
    module:
      type: stream
      path: "%kernel.logs_dir%/modules.json.log"
      level: info
      channels: ["module"]
      formatter: monolog.formatter.json

Pour que ça reste exploitable dans la vraie vie, pensez “format d’analyse”, pas “texte lisible” : un log JSON est moche dans un terminal, mais excellent pour filtrer “toutes les erreurs du module X depuis la release Y sur le shop Z”.

Exemple de ligne JSON (simplifié, à titre illustratif) :

{
  "level": "error",
  "message": "Payment capture failed",
  "context": { "order_id": 12345, "amount": 79.90, "module": "mygateway" },
  "channel": "module",
  "datetime": "2026-04-02T10:15:02+00:00"
}

Même si vous gardez PrestaShopLogger::addLog() pour la visibilité BO (support, exploitation), considérez-le comme un miroir, pas comme la source de vérité : volume limité, requêtes SQL, difficulté d’extraction, et risque de saturer la base en cas de boucle d’erreurs.

Une bonne pratique simple en e-commerce : séparer “erreurs” et “audit métier”. Exemple : un échec de capture de paiement (erreur) n’a pas le même cycle de vie qu’un changement de statut commande (audit). Mélanger les deux rend les alertes inutilisables (trop de bruit) et complique la conformité (durées de conservation, données personnelles).

Patterns de gestion d’exception dans les modules : attraper moins, enrichir plus

La mauvaise pratique la plus fréquente en module : catch (Exception $e) { return false; }. Ça “résout” l’erreur en surface et vous laisse une boutique qui dysfonctionne silencieusement. Un pattern robuste : (1) capturer seulement ce que vous pouvez traiter, (2) journaliser avec contexte, (3) relancer une exception métier (avec chaînage) si l’appelant doit décider. En PHP 8.x, utilisez systématiquement Throwable si vous voulez couvrir Error + Exception.

Exemple (PrestaShop 9.1, PHP 8.2+) : injection PSR-3, log contextuel, exception chaînée.

use Psr\Log\LoggerInterface;

final class MyGatewayService
{
    public function __construct(private LoggerInterface $logger) {}

    public function capture(int $orderId, float $amount): void
    {
        try {
            // appel API externe
        } catch (\Throwable $e) {
            $this->logger->error('Payment capture failed', [
                'order_id' => $orderId,
                'amount' => $amount,
                'exception' => $e, // Monolog sait sérialiser via processors/normalizers
            ]);

            throw new \RuntimeException(
                sprintf('Capture impossible pour la commande %d', $orderId),
                previous: $e
            );
        }
    }
}

Ce pattern devient encore plus utile quand vous avez plusieurs couches (contrôleur → service → client HTTP). Sans chaînage (previous), vous perdez souvent l’exception racine (timeout, 401, JSON invalide) et vous ne voyez qu’un “Erreur lors du paiement” inexploitable.

Trois détails qui font la différence en prod :

1) Ne journalisez pas les secrets (tokens, numéros complets, payloads bruts). Si vous devez tracer, hachez ou tronquez (ex. tok_***abcd), et supprimez les PII non nécessaires.
2) Catégorisez (channel “module”, “payment”, “import”) : sinon vous noyez le signal.
3) Évitez les retries aveugles : si vous retentez un appel externe, faites-le avec backoff, limite, et idempotence (sinon double débit, double stock, etc.).

Astuce “qualité” pour les modules : préférez des exceptions métier explicites plutôt que des booléens ambigus. Exemple : PaymentCaptureFailedException vs return false. En support, ça réduit le temps de diagnostic parce que vos journaux peuvent agréger par type d’incident (même message, même code), au lieu d’un empilement de messages “à la main”.

Dans le legacy, vous pouvez encore tomber sur des points où l’injection de dépendances n’est pas disponible. À ce moment, journalisez via PrestaShopLogger::addLog() mais conservez une trace technique exploitable (message stable, code d’erreur, identifiant fonctionnel), et remontez l’exception si l’action doit être rollbackée.

Dev vs prod : convertir les warnings en exceptions en CI, et déployer sans se tirer une balle dans le pied

La configuration “prod” vise la stabilité et la confidentialité ; la configuration “dev/CI” vise la détection précoce. La stratégie efficace en 2026, avec PHP 8.x, consiste à traiter les E_WARNING/E_DEPRECATED comme des régressions en pipeline (au moins sur vos packages/modules). Vous pouvez, dans un bootstrap de tests, convertir les erreurs en ErrorException via set_error_handler() pour forcer la correction plutôt que l’ignorance.

Exemple minimal (à activer uniquement dans un contexte de test/CI) :

set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
    // Respecte error_reporting() (utile si vous filtrez certains niveaux en local)
    if (!(error_reporting() & $severity)) {
        return false;
    }
    throw new \ErrorException($message, 0, $severity, $file, $line);
});

L’intérêt pratique : un E_DEPRECATED introduit par un module (ou une surcharge) devient un test rouge dès aujourd’hui, au lieu d’un incident lors d’un passage PHP 8.1 → 8.3/8.4.

Pour l’écosystème Symfony, symfony/phpunit-bridge reste une option solide pour suivre les dépréciations et fixer un budget (ex. “zéro déprecation introduite”). Ajoutez une analyse statique (PHPStan/Psalm) avec un niveau adapté à votre codebase PrestaShop (beaucoup de legacy implique parfois de segmenter par dossiers). Le but n’est pas de “faire joli”, c’est d’éviter la situation typique : “ça marche en PHP 8.1 mais ça casse en 8.3/8.4”.

Côté déploiement, la checklist qui évite 80% des incidents liés à la gestion d’erreur est triviale mais rarement appliquée :

  • Vérifier FPM + CLI (deux php.ini, deux error_log).
  • display_errors=Off en prod, systématiquement.
  • log_errors=On + rotation validée + droits écriture.
  • Cache Symfony : purge contrôlée (var/cache/prod), pas de suppression “au hasard” en pleine charge.
  • Un identifiant de release dans les journaux (commit SHA / tag) pour corréler un pic d’erreurs à un déploiement.
  • Un rollback clair (artifact/version) : sinon votre “fix” devient une opération en prod.

Ajout simple mais très rentable : un “smoke test” post-déploiement. Même un test basique (homepage + ajout panier + accès BO) détecte rapidement les erreurs “trop tôt” (autoload, container Symfony, permissions cache). Sur PrestaShop, c’est souvent la différence entre un incident vu en interne et un incident vu par les clients.

Si vous devez intervenir sur un serveur en incident, gardez en tête le risque principal : en activant l’affichage d’erreurs ou un debug verbeux, vous pouvez exposer des données sensibles à des visiteurs anonymes (ou à des bots). Le bon réflexe est donc : augmenter la verbosité côté journaux, pas côté HTML.


À lire aussi