WordPress
15 juillet 2026

BOM UTF-8 dans un module PrestaShop : quand 3 octets cassent l’AJAX

Deux appels AJAX front qui tombent du jour au lendemain, une erreur « headers already sent » dans les logs, et pas une ligne de code métier modifiée. Le coupable ? Trois octets invisibles — un BOM UTF-8 — au tout début de fichiers PHP de traduction d’un module tiers. Voici comment on a remonté la piste jusqu’aux octets initiaux, ce qu’on a fait pour remettre la boutique d’aplomb en quelques minutes, et le garde-fou qu’on a posé pour ne plus jamais revivre ça.

Ce que vous allez apprendre :

  • Pourquoi un BOM UTF-8 de 3 octets avant <?php casse les headers HTTP et donc l’AJAX
  • Comment diagnostiquer le problème au niveau octet (avec file, hexdump, grep)
  • Le hotfix de prod pour remettre les appels AJAX d’aplomb rapidement
  • Le correctif pérenne dans le repo, sans toucher aux traductions ni aux accents
  • Un garde-fou CI réutilisable qui bloque tout fichier PHP livré avec un BOM

C’est typiquement le genre de bug qui fait perdre une demi-journée parce qu’on cherche au mauvais endroit. Rien dans la logique métier n’a l’air faux. Le code de la fonctionnalité est intact. Et pourtant, sur l’environnement exposé, deux appels AJAX front critiques renvoient une réponse corrompue, illisible côté JavaScript.

Quand une réponse AJAX casse sans qu’aucune ligne de code métier n’ait bougé, le problème n’est presque jamais dans la logique. Il est dans ce qui est envoyé avant elle.

Chez Mintfull, on a traité ce cas précis sur un projet PrestaShop. Voici le déroulé complet, du symptôme jusqu’au garde-fou.

Le symptôme : des appels AJAX qui tombent sans raison apparente

Le point de départ, c’est toujours le même : quelque chose qui fonctionnait ne fonctionne plus, et le front ne dit rien d’utile.

👉 Côté navigateur : deux appels AJAX front critiques renvoient une réponse que le JavaScript n’arrive pas à parser. Le bloc concerné ne se met plus à jour, ou affiche une erreur silencieuse.

👉 Côté serveur, dans les logs PHP, le message qui doit immédiatement vous mettre la puce à l’oreille :

PHP Warning:  Cannot modify header information - headers already sent
by (output started at /modules/.../translations/fr.php:1)

Ce message est précieux. Il vous dit trois choses :

  • Quelque chose a déjà été envoyé au navigateur avant que PHP ne tente de poser ses headers ;
  • L’envoi a commencé (output started at) dans un fichier de traduction ;
  • À la ligne 1, c’est-à-dire avant même la moindre instruction PHP.

Or, sur une réponse AJAX, PrestaShop a besoin d’envoyer ses propres en-têtes (Content-Type: application/json notamment) avant le corps de la réponse. Si le moindre octet est sorti avant, le header ne part pas correctement, le navigateur reçoit un Content-Type erroné ou un corps préfixé par du bruit, et le parsing JSON échoue. L’appel AJAX « tombe ».

« Headers already sent » ne veut pas dire que votre code envoie des headers en double. Ça veut dire que quelque chose a écrit dans le flux de sortie avant vous — souvent un caractère que vous ne voyez même pas.

Le diagnostic : descendre jusqu’aux octets initiaux

Le log pointe vers un fichier de traduction à la ligne 1. On ouvre le fichier dans l’éditeur… et tout a l’air parfaitement normal. Le <?php est bien là, en première ligne, sans espace avant. C’est exactement à ce moment-là qu’il faut arrêter de faire confiance à l’affichage et regarder les octets bruts.

Qu’est-ce qu’un BOM UTF-8 ?

Le BOM (Byte Order Mark) est une séquence d’octets optionnelle placée tout au début d’un fichier texte pour signaler son encodage. En UTF-8, ce marqueur vaut trois octets : 0xEF 0xBB 0xBF. Certains éditeurs Windows, ou des outils de génération de fichiers de traduction, l’ajoutent automatiquement « pour bien faire ».

Le problème, c’est qu’en PHP ces trois octets ne sont pas du code. Ils sont avant la balise <?php. PHP les traite donc comme du contenu à émettre tel quel vers la sortie. Trois octets, certes invisibles à l’œil dans un éditeur qui interprète le BOM, mais bien réels dans le flux HTTP.

👉 Trois octets envoyés avant les headers, c’est tout ce qu’il faut pour déclencher « headers already sent ». Le contenu du fichier de traduction est sans importance : le mal est fait dès l’octet n°1.

Repérer le BOM concrètement

Première vérification, la commande file vous le dit directement :

$ file modules/un_module_tiers/translations/fr.php
modules/un_module_tiers/translations/fr.php: PHP script, Unicode text, UTF-8 (with BOM) text

Le (with BOM) est la confirmation. Pour voir les octets de vos propres yeux, hexdump sur le début du fichier :

$ hexdump -C modules/un_module_tiers/translations/fr.php | head
00000000  ef bb bf 3c 3f 70 68 70  0a 0a 24 5f 4d 4f 44 55  |...<?php..$_MODU|
00000010  ...

Les trois premiers octets ef bb bf avant 3c 3f 70 68 70 (qui est <?php en ASCII), c’est exactement le BOM en train de polluer le début du fichier.

Pour trouver tous les fichiers concernés d’un coup, un grep sur le motif binaire en début de ligne :

$ grep -rlP '^\xEF\xBB\xBF' --include='*.php' modules/un_module_tiers/
modules/un_module_tiers/translations/fr.php
modules/un_module_tiers/translations/en.php
modules/un_module_tiers/translations/es.php

👉 Et là, surprise : ce n’est pas un fichier isolé, mais plusieurs fichiers PHP de traduction du même module tiers qui embarquent le BOM. C’est cohérent : ils ont tous été générés par le même outil, avec le même réglage d’encodage fautif.

Pourquoi c’est si piégeux sur PrestaShop

Ce bug est sournois pour une raison simple : il ne vient pas de la logique métier. Votre code de contrôleur AJAX est correct. Vos templates sont corrects. La fonctionnalité a été testée et validée. Rien dans ce que vous avez écrit n’est en cause.

Le runtime PrestaShop charge ces fichiers de traduction via des include au fil de l’exécution, souvent très tôt dans le cycle de vie de la requête. Dès qu’un de ces fichiers est inclus, ses trois octets de BOM partent dans le flux de sortie — et le sort de tous les headers suivants est scellé. Comme le déclenchement dépend du parcours de code (un appel AJAX charge tel module et tel fichier de langue, une page classique pas forcément), le bug peut sembler intermittent et n’affecter que certains points d’entrée. D’où l’impression que « ça marche parfois ».

C’est la même famille de pièges discrets que ceux qu’on traque quand on diagnostique un back-office PrestaShop lent : le symptôme est spectaculaire, la cause tient dans un détail qu’on ne pense pas à regarder.

Le hotfix de prod : retirer le BOM pour remettre l’AJAX d’aplomb

Première priorité, en production : remettre les deux appels AJAX en service. On ne refactore rien, on enlève juste les trois octets fautifs en tête de fichier.

Sur un fichier précis, sed supprime le BOM de la première ligne uniquement :

$ sed -i '1s/^\xEF\xBB\xBF//' modules/un_module_tiers/translations/fr.php

Pour traiter tous les fichiers concernés d’un coup, on combine avec le grep de détection dans une boucle find :

$ find modules/un_module_tiers/ -type f -name '*.php' \
    -exec grep -qlP '^\xEF\xBB\xBF' {} \; -print | while read -r f; do
    sed -i '1s/^\xEF\xBB\xBF//' "$f"
  done

👉 Dès le BOM retiré, le flux de sortie redevient propre, les headers HTTP repartent normalement, et les deux appels AJAX front se remettent à répondre du JSON valide. Le service est rétabli.

Un hotfix doit rétablir le service vite et sans risque. Retirer un BOM ne touche ni à la logique, ni au contenu des traductions — c’est l’un des correctifs de prod les plus sûrs qui soient.

Mais un hotfix appliqué directement sur l’environnement exposé n’est qu’un pansement : au prochain déploiement, les fichiers du repo écrasent les versions corrigées et le BOM revient. Il faut donc remonter le correctif dans le repository.

Le correctif pérenne dans le repo

L’objectif côté repo est clair : nettoyer les mêmes fichiers, sans toucher au code métier ni au contenu des traductions. Les chaînes traduites, accents compris, doivent rester strictement identiques — on enlève uniquement les trois octets de tête.

On rejoue exactement la même opération que le hotfix, mais cette fois sur les fichiers versionnés, puis on commit :

$ grep -rlP '^\xEF\xBB\xBF' --include='*.php' modules/un_module_tiers/ \
  | while read -r f; do
      sed -i '1s/^\xEF\xBB\xBF//' "$f"
    done

Le diff doit être minimal et lisible : sur chaque fichier touché, une seule ligne modifiée, la première, et la seule différence visible dans le diff binaire est la disparition des octets ef bb bf. Aucune chaîne traduite ne doit apparaître comme modifiée.

Valider avant de pousser

On ne pousse pas un correctif d’encodage sans le vérifier. Quatre contrôles, dans l’ordre :

  1. Lint PHP sur chaque fichier touché, pour garantir qu’on n’a rien cassé syntaxiquement :
    $ php -l modules/un_module_tiers/translations/fr.php
    No syntax errors detected in modules/un_module_tiers/translations/fr.php
  2. Contrôle d’encodage : file ne doit plus mentionner with BOM, et le grep de détection ne doit plus rien retourner :
    $ grep -rlP '^\xEF\xBB\xBF' --include='*.php' modules/un_module_tiers/
    $  # (aucune sortie = aucun fichier avec BOM)
  3. Vérification des accents : on ouvre quelques traductions et on contrôle que les caractères accentués (é, è, à, ç…) sont intacts. Retirer le BOM ne change rien à l’UTF-8 du contenu, mais on confirme plutôt que de supposer.
  4. Tests front sur les appels AJAX impactés : on rejoue les deux scénarios qui tombaient et on vérifie, onglet réseau du navigateur, que la réponse a bien un Content-Type JSON correct et un corps valide.

👉 Ces quatre contrôles tiennent en cinq minutes et ferment définitivement le sujet pour ce module. Reste à empêcher qu’un autre fichier vérolé revienne par une porte différente.

Le garde-fou : bloquer tout fichier PHP livré avec un BOM

Corriger une fois, c’est bien. Empêcher le retour du problème, c’est mieux. Un module tiers se met à jour, on en installe un nouveau, et le prochain paquet peut très bien réintroduire des fichiers PHP avec BOM. On automatise donc la détection.

Le cœur du garde-fou est un petit script bash qui scanne les fichiers .php et sort en erreur dès qu’il trouve un BOM :

#!/usr/bin/env bash
set -euo pipefail

# Scanne les fichiers PHP livrés et échoue si un BOM UTF-8 est présent.
SCAN_DIR="${1:-modules}"

offenders="$(grep -rlP '^\xEF\xBB\xBF' --include='*.php' "$SCAN_DIR" || true)"

if [ -n "$offenders" ]; then
    echo "BOM UTF-8 détecté dans les fichiers PHP suivants :"
    echo "$offenders"
    echo "Retirez le BOM avant de committer (sed -i '1s/^\\xEF\\xBB\\xBF//' fichier)."
    exit 1
fi

echo "OK : aucun fichier PHP avec BOM."

Piège : ce script ne tourne pas sur Alpine

Ce script rend service, mais il repose sur deux options que seul GNU grep connaît : -P (motifs PCRE) et --include. Or beaucoup d’images de CI sont basées sur Alpine, dont le grep est celui de busybox. Et busybox ne supporte ni l’une ni l’autre :

$ grep -rlP '^\xEF\xBB\xBF' --include='*.php' .
grep: unrecognized option: P
BusyBox v1.37.0 multi-call binary.

👉 Le risque est doublement pénible. Soit le job échoue pour une raison qui n’a rien à voir avec un BOM, et vous cherchez au mauvais endroit. Soit, selon la façon dont le script gère les codes de retour, il ne détecte plus rien et passe au vert en silence.

Un garde-fou qui ne garde rien est pire que pas de garde-fou du tout : il vous donne une fausse confiance. Un check de CI doit être testé dans l’image où il tournera vraiment, pas seulement sur le Debian de votre poste.

La parade est de ne dépendre ni du PCRE ni de --include, et de lire directement les trois premiers octets avec od. Cette version est en POSIX sh pur, et elle tourne aussi bien sur Debian (dash) que sur Alpine (busybox) :

#!/usr/bin/env sh
set -eu

# Fails if any PHP file starts with a UTF-8 BOM (0xEF 0xBB 0xBF).
# POSIX sh + od only: works on Debian/dash, Alpine/busybox and macOS alike.
SCAN_DIR="${1:-modules}"

offenders="$(find "$SCAN_DIR" -type f -name '*.php' -print | while IFS= read -r f; do
    if [ "$(od -An -N3 -tx1 < "$f" | tr -d ' \n')" = "efbbbf" ]; then
        printf '%s\n' "$f"
    fi
done)"

if [ -n "$offenders" ]; then
    echo "BOM UTF-8 detected in the following PHP files:"
    echo "$offenders"
    echo "Strip it (portable, no \\x escapes needed):"
    echo "  tail -c +4 <file> > f.tmp && mv f.tmp <file>"
    exit 1
fi

echo "OK: no PHP file with a BOM."

👉 Dans la même veine, attention aussi au nettoyage : busybox sed n’interprète pas les échappements \xEF. Le sed -i '1s/^\xEF\xBB\xBF//' du hotfix fonctionne très bien sur une Debian ou dans votre conteneur PHP, mais sur Alpine il ne retire rien du tout, et sans broncher. Si vous devez nettoyer un fichier dans une image busybox, tail -c +4 fichier > f.tmp && mv f.tmp fichier saute les trois octets sans dépendre d’aucun échappement.

Ce script se branche à deux endroits utiles.

En hook pre-commit (local)

Pour attraper le problème avant même qu’il n’entre dans l’historique Git, un hook .git/hooks/pre-commit (ou un hook géré par votre outil habituel) qui appelle le script :

#!/usr/bin/env bash
# .git/hooks/pre-commit
./tools/ci/check-php-bom.sh modules || exit 1

👉 Le développeur est averti immédiatement, au moment où c’est le moins cher à corriger.

En job CI (filet de sécurité)

Le hook local peut être contourné (un --no-verify est vite tapé), donc on double avec un job dans la CI qui, lui, ne se contourne pas :

check-php-bom:
  stage: lint
  script:
    - ./tools/ci/check-php-bom.sh modules
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

Toute merge request qui introduirait un fichier PHP avec BOM — dans un module tiers comme ailleurs — fait échouer le pipeline et ne peut pas être mergée en l’état. Le bug devient structurellement impossible à réintroduire sans qu’on le voie.

Un bug qu’on a corrigé une fois mais qu’on n’a pas rendu impossible à reproduire, c’est un bug qui reviendra. Le garde-fou n’est pas un bonus : c’est la vraie fin du ticket.

Notre avis chez Mintfull

Ce genre de bug est frustrant sur le moment, mais c’est une excellente piqûre de rappel : les pannes les plus déroutantes ne viennent pas toujours de la logique qu’on a écrite. Parfois, elles tiennent dans trois octets qu’aucun éditeur ne montre.

La leçon qu’on en tire, ce n’est pas « méfiez-vous des modules tiers » — même si la vigilance sur ce qu’on intègre dans un PrestaShop reste de mise. C’est plutôt : quand le symptôme ne colle pas avec le code, descendez d’un niveau. Du métier vers le runtime, du fichier vers les octets. Le message « headers already sent » est un cadeau : il vous donne le fichier et la ligne. Encore faut-il accepter d’aller regarder ce qui se cache avant la première ligne visible.

👉 Et surtout, transformez chaque incident en garde-fou. Un script de dix lignes dans la CI vaut mieux qu’une demi-journée de débogage tous les six mois. C’est exactement la philosophie qu’on applique sur tous nos projets, qu’il s’agisse d’encodage, de performances ou de la préparation d’une montée vers PrestaShop 9.

FAQ : BOM UTF-8 et headers HTTP en PHP

Pourquoi un BOM casse-t-il les headers alors que le fichier a l’air normal ?

Parce que le BOM est constitué de trois octets (0xEF 0xBB 0xBF) placés avant la balise <?php. PHP les considère comme du contenu à émettre, pas comme du code. Ils partent donc dans le flux de sortie dès l’inclusion du fichier, ce qui empêche tout envoi de header ensuite. Votre éditeur, lui, interprète le BOM et ne l’affiche pas : d’où l’impression que le fichier est « normal ».

Comment savoir si un de mes fichiers PHP contient un BOM ?

Le plus rapide : file mon_fichier.php, qui affiche (with BOM) le cas échéant. Pour voir les octets, hexdump -C mon_fichier.php | head et regardez si la sortie commence par ef bb bf. Pour scanner une arborescence entière, grep -rlP '^\xEF\xBB\xBF' --include='*.php' . liste tous les fichiers concernés.

Retirer le BOM peut-il abîmer mes traductions ou mes accents ?

Non. Le BOM n’est qu’un marqueur en tête de fichier, totalement distinct du contenu UTF-8 qui suit. Le retirer avec sed -i '1s/^\xEF\xBB\xBF//' fichier ne modifie que les trois premiers octets et laisse les caractères accentués strictement intacts. On le vérifie quand même systématiquement après coup, par principe.

Le BOM est-il toujours un problème en PHP ?

Dès qu’un fichier PHP est censé démarrer par <?php sans rien produire en sortie — ce qui est le cas de la quasi-totalité des fichiers de classes, de configuration et de traduction — oui, le BOM est un problème, parce qu’il génère une sortie parasite. La règle simple : aucun fichier PHP ne devrait jamais commencer par un BOM. D’où l’intérêt d’un check automatisé qui l’interdit dans toute la base de code.

Conclusion : trois octets, une méthode

Un BOM UTF-8 dans des fichiers de traduction d’un module tiers, et voilà deux appels AJAX front à terre avec un « headers already sent » en guise d’unique indice. La cause est minuscule, mais la méthode pour la traiter est généralisable à beaucoup de bugs « invisibles ».

👉 Le bon réflexe, dans l’ordre :

  1. Lire le message d’erreur jusqu’au bout — il vous donne le fichier et la ligne
  2. Descendre au niveau octet avec file et hexdump quand l’affichage ne suffit plus
  3. Rétablir le service avec un hotfix sûr et ciblé
  4. Corriger dans le repo proprement, puis valider (lint, encodage, accents, AJAX)
  5. Poser un garde-fou pour rendre le bug impossible à réintroduire

Le débogage, ce n’est pas deviner. C’est descendre méthodiquement jusqu’à l’endroit où la réalité diffère de ce qu’on croyait.


Un bug PrestaShop « impossible » qui résiste, un module tiers qui se comporte mal, ou un front qui casse sans explication ?

👉 Notre agence PrestaShop traque ce genre de problème jusqu’à l’octet près : diagnostic rigoureux, correctif sûr en production, et garde-fous automatisés pour que le bug ne revienne jamais. Sans toucher à vos ventes ni à votre front.

Parce qu’un bug bien diagnostiqué, c’est un bug qui ne revient pas.

Nous vous recommandons aussi