Un PO remonte un bug sur un tunnel de souscription en ligne : son numéro de téléphone est rejeté côté serveur, alors qu’il s’affiche correctement à l’écran et que le drapeau pays est bien sélectionné. Impossible à reproduire sur nos postes (Chrome, Firefox desktop). Le numéro est valide, visuellement irréprochable. Et pourtant, 422.
Symptôme
Le composant front utilisait intl-tel-input (le widget avec drapeau + indicatif). Côté serveur, une validation maison à base de regex, du genre :
// Validation "maison" du numéro normalisé
if (preg_match('/^\+\d{1,3}[\s\d]{6,}$/', $phone) !== 1) {
throw new BadRequestHttpException('Numéro de téléphone international invalide');
}
La regex semblait propre : un +, un indicatif, puis des chiffres et des espaces. Sauf que pour ce PO — et seulement sur son device — elle ne matchait jamais.
Le réflexe qui débloque tout : ne pas regarder le numéro affiché, mais ses octets bruts. Un bin2hex() sur la valeur reçue côté serveur :
error_log(bin2hex($phone));
// 2b3333 c2a0 363831353930323336
// + 3 3 ↑↑↑↑ 6 8 1 5 9 0 ...
// \____ C2 A0 = UTF-8 de U+00A0 (NBSP)
c2a0, c’est l’encodage UTF-8 de U+00A0 — l’espace insécable (NBSP). iOS Safari l’avait injectée entre l’indicatif et la suite du numéro : +33\u{00A0}681590236.
Cause
intl-tel-input (et/ou le clavier iOS) formate le numéro à l’affichage en insérant une espace insécable comme séparateur visuel entre l’indicatif et le reste. C’est joli à l’écran. Mais côté serveur, \s en PCRE (mode par défaut, sans /u) ne matche que les espaces ASCII : espace, tab, CR, LF, FF, VT. U+00A0 n’en fait pas partie.
Résultat : un caractère invisible, indistinguable d’une espace normale à l’œil, fait échouer une regex qui « a l’air » correcte. Et comme nos claviers desktop n’injectent jamais de NBSP, le bug est invisible en dev et ne sort que sur device iOS réel.
C’est un piège classique de la validation par regex sur de la donnée saisie : on raisonne sur les caractères qu’on voit, pas sur les octets qui arrivent.
Solution
On a arrêté de jouer au chat et à la souris avec les espaces Unicode. La validation d’un numéro de téléphone est un problème déjà résolu : libphonenumber (la bibliothèque de Google, portée en PHP), la même autorité que celle utilisée par intl-tel-input côté JS.
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
final class PhoneNumberValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (null === $value || '' === $value) {
return;
}
$phoneUtil = PhoneNumberUtil::getInstance();
try {
$number = $phoneUtil->parse($value, $constraint->defaultRegion);
if (!$phoneUtil->isValidNumber($number)) {
$this->context->buildViolation($constraint->message)->addViolation();
}
} catch (NumberParseException) {
$this->context->buildViolation($constraint->message)->addViolation();
}
}
}
libphonenumber strippe lui-même les séparateurs (espaces ASCII, NBSP, points, tirets), comprend les formats locaux et E.164, et valide le numéro pour de vrai (longueur correcte selon le pays, préfixe d’opérateur plausible…). Plus de regex à maintenir.
Pour le besoin spécifique « normaliser vers E.164 » (un prestataire de signature électronique en aval exigeait du +33… strict), on a aussi extrait un petit Value Object immutable qui nettoie d’abord les séparateurs courants, avant de valider la forme :
final class PhoneE164
{
private const STRIP_PATTERN = '/[\s\.\-]/u'; // /u → \s couvre aussi le NBSP
private const E164_FULL_PATTERN = '/^\+\d{8,15}$/';
private const FR_LOCAL_PATTERN = '/^0\d{9}$/';
private function __construct(public readonly string $value) {}
public static function fromUserInput(string $raw): self
{
$clean = preg_replace(self::STRIP_PATTERN, '', $raw) ?? '';
if ($clean === '') {
throw new \InvalidArgumentException('Numéro de téléphone vide');
}
if (str_starts_with($clean, '+')) {
if (preg_match(self::E164_FULL_PATTERN, $clean) !== 1) {
throw new \InvalidArgumentException("Numéro E.164 invalide : \"$raw\"");
}
return new self($clean);
}
if (preg_match(self::FR_LOCAL_PATTERN, $clean) === 1) {
return new self('+33' . substr($clean, 1)); // 0612345678 → +33612345678
}
throw new \InvalidArgumentException("Numéro non reconnu : \"$raw\"");
}
}
Le détail qui compte : le flag /u sur le STRIP_PATTERN. En mode Unicode, \s couvre aussi U+00A0 — donc le NBSP est éliminé au nettoyage, avant même la validation de forme. Et un jeu de tests qui couvre explicitement le cas du PO :
public static function provideValidInputs(): iterable
{
yield 'FR compact' => ['0612345678', '+33612345678'];
yield 'FR espaces' => ['06 12 34 56 78', '+33612345678'];
yield 'FR points' => ['06.12.34.56.78', '+33612345678'];
yield 'E164 avec NBSP' => ["+33\u{00A0}612345678", '+33612345678']; // le bug iOS
yield 'UK E164' => ['+44123456789', '+44123456789'];
}
Lessons learned
\sen PCRE ne couvre pas les espaces Unicode (NBSP U+00A0, fine U+202F, etc.) tant que le flag/un’est pas posé. iOS Safari et les claviers mobiles adorent injecter des NBSP comme séparateurs d’affichage. Sur de la saisie utilisateur, soit on ajoute/u, soit on n’utilise pas de regex du tout.- Quand un input « visuellement correct » est rejeté, regardez les octets, pas les caractères.
bin2hex()(PHP),Buffer.from(s).toString('hex')(Node),xxd(shell) révèlent en deux secondes le caractère invisible. La donnée affichée ment ; les octets, non. - Pour valider de la donnée métier normée (téléphone, IBAN, BIC, email…), prenez une bibliothèque spécialisée, pas une regex maison. libphonenumber gère 200+ pays, les séparateurs exotiques et la validation réelle — ce qu’aucune regex raisonnable ne fera.
- Alignez l’autorité de validation front et back. Si le JS valide avec
intl-tel-input(basé sur libphonenumber) et que le serveur valide avec une regex artisanale, les deux divergeront forcément un jour. Même lib des deux côtés = même verdict. - Testez avec un device iOS réel, pas seulement le simulateur responsive de Chrome DevTools. Ce bug-là n’existe que sur le vrai moteur Safari + clavier iOS.
#ios #safari #php #symfony #validation #libphonenumber #ux