Un formulaire d'ouverture de compte professionnel demande un IBAN pour le prélèvement et un numéro de TVA intracommunautaire pour la facture. Une expression régulière vérifie la forme, pas la clé de contrôle : un IBAN avec un chiffre faux passe sans problème. La bonne méthode tient en deux étapes : une regex pour le format, puis le calcul de la clé (modulo 97 dans les deux cas). Tout le code ci-dessous a été exécuté dans une application Laravel, avec des exemples valides et invalides.
Pourquoi l'ancienne règle ne suffisait pas ?
La version précédente de cette note proposait 'regex:^[a-zA-Z]{2}[0-9]{2}...$'. Nous l'avons testée, elle cumule trois défauts :
- Pas de délimiteurs. La documentation de Laravel le rappelle : la règle utilise
preg_match, le motif "should obey the same formatting required bypreg_matchand thus also include valid delimiters". Sans/.../, la validation lève uneErrorException:preg_match(): No ending delimiter '^' found. sau lieu de\sdans la regex de TVA :[-_s0-9]accepte la lettres, si bien queFRsssétait accepté.- Aucun calcul de clé.
FR1520041010050500013M02606, un IBAN d'exemple dont nous avons changé la clé, passait la regex.
Comment vérifier la clé d'un IBAN ?
L'IBAN (norme ISO 13616) commence par deux lettres de pays et deux chiffres de contrôle calculés selon la méthode "MOD 97-10" de la norme ISO 7064. Pour le vérifier, il faut déplacer les quatre premiers caractères à la fin, remplacer chaque lettre par un nombre (A = 10 ... Z = 35), et l'IBAN est valide si le reste de la division par 97 vaut 1 (méthode décrite notamment dans les textes officiels de la banque centrale de Turquie sur l'IBAN).
<?php // app/Support/BankIds.php namespace App\Support; final class BankIds { // Removes spaces and dashes, uppercases public static function normalize(string $value): string { return strtoupper(preg_replace('/[\s-]+/', '', $value)); } public static function isIban(string $value): bool { $iban = self::normalize($value); // 2 letters, 2 check digits, then 11 to 30 letters or digits (34 max) if (! preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/', $iban)) { return false; } // Move the first 4 characters to the end, then A=10 ... Z=35 $rearranged = substr($iban, 4).substr($iban, 0, 4); $digits = ''; foreach (str_split($rearranged) as $char) { $digits .= ctype_alpha($char) ? (string) (ord($char) - 55) : $char; } // Modulo 97 computed by chunks to stay within PHP integers $remainder = 0; foreach (str_split($digits, 7) as $chunk) { $remainder = (int) ($remainder.$chunk) % 97; } return $remainder === 1; } public static function isFrenchVat(string $value): bool { $vat = self::normalize($value); // FR + 2-character key + 9-digit SIREN if (! preg_match('/^FR([0-9A-Z]{2})([0-9]{9})$/', $vat, $m)) { return false; } [, $key, $siren] = $m; // Numeric key: (12 + 3 x (SIREN mod 97)) mod 97 if (ctype_digit($key)) { return (int) $key === (12 + 3 * ((int) $siren % 97)) % 97; } // Key with letters: format only return true; } }
Le nombre obtenu dépasse largement la capacité d'un entier PHP : le calcul se fait donc par tranches de 7 chiffres, en reportant le reste. Résultats : FR1420041010050500013M02606, DE89370400440532013000, GB29NWBK60161331926819 et BE68539007547034 (IBAN d'exemple, pas de vrais comptes) sont acceptés, y compris saisis avec des espaces, des tirets ou en minuscules. Une clé modifiée, un dernier chiffre changé ou un caractère en moins sont refusés.
Pour aller plus loin, on peut contrôler la longueur propre à chaque pays (27 caractères pour un IBAN français), mais le calcul de la clé détecte déjà les fautes de frappe.
Comment contrôler un numéro de TVA français ?
Le numéro français s'écrit FR, une clé de 2 caractères, puis les 9 chiffres du SIREN. Quand la clé est numérique, elle se calcule ainsi : (12 + 3 × (SIREN modulo 97)) modulo 97. Pour le SIREN fictif 123456789, la fonction ci-dessus accepte FR32123456789 et refuse FR23123456789. Une clé contenant des lettres existe aussi : elle n'est contrôlée que sur la forme.
Ce calcul détecte une faute de saisie, pas un numéro inventé. Nous n'avons pas trouvé la formule de la clé dans une publication de l'administration fiscale ; elle est reprise partout mais reste à prendre comme un contrôle de forme. Pour savoir si un numéro est réellement attribué à une entreprise, le service officiel est VIES, de la Commission européenne.
Comment brancher ces contrôles dans la validation Laravel ?
Une classe de règle (générée avec php artisan make:rule Iban) pour l'IBAN, réutilisable partout :
<?php // app/Rules/Iban.php namespace App\Rules; use App\Support\BankIds; use Closure; use Illuminate\Contracts\Validation\ValidationRule; class Iban implements ValidationRule { public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value) || ! BankIds::isIban($value)) { $fail('Le champ :attribute doit contenir un IBAN valide.'); } } }
Et une fonction anonyme pour la TVA, si elle ne sert qu'à un endroit :
<?php // app/Http/Controllers/AccountController.php (extrait) use App\Rules\Iban; use App\Support\BankIds; use Closure; use Illuminate\Support\Facades\Validator; $validator = Validator::make($request->all(), [ 'iban' => ['required', 'string', new Iban], 'tva' => ['nullable', 'string', function (string $attribute, mixed $value, Closure $fail) { if (! BankIds::isFrenchVat($value)) { $fail('Le numéro de TVA intracommunautaire n\'est pas valide.'); } }], ]);
Résultats : FR14 2004 1010 0505 0001 3M02 606 et FR 32 123 456 789 passent ; une clé fausse donne les deux messages ; une TVA vide passe, car Laravel n'exécute pas une règle personnalisée sur un champ vide (sauf règle "implicit") ; un IBAN vide est arrêté par required. Pensez à enregistrer la valeur normalisée (BankIds::normalize()), pas la saisie brute.
Et si l'on garde une simple règle regex ?
Pour un contrôle de forme seul, mettez les délimiteurs et passez par un tableau ou par Rule::regex() :
<?php // Format only, no check digits use Illuminate\Validation\Rule; 'iban' => ['required', 'regex:/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/'], 'iban_bis' => [Rule::regex('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/')],
N'utilisez pas la syntaxe avec des barres verticales dès que la regex contient un | : 'required|regex:/^(FR|BE)[0-9]{2}/' est coupé au milieu du motif et lève preg_match(): No ending delimiter '/' found. La même règle écrite en tableau fonctionne.
Ces contrôles sont typiques des formulaires d'une application sur mesure ou des données envoyées à un logiciel de gestion, où une saisie erronée se retrouve ensuite partout (connecter vos logiciels). Autre cas de validation de formulaire : pluck() pour remplir un select.
- Une regex vérifie la forme ; la clé d'un IBAN se vérifie par le modulo 97 (reste égal à 1).
- TVA française :
FR+ clé + SIREN, clé numérique =(12 + 3 × (SIREN mod 97)) mod 97; VIES pour savoir si le numéro est attribué. - Dans Laravel,
regex:exige des délimiteurs ; dès qu'un|est dans le motif, écrire les règles en tableau. - Une classe
ValidationRulegarde le contrôle réutilisable et testable.
Testé avec Laravel 13.37.0 et PHP 8.3.6.
Questions fréquentes
Faut-il un package pour valider un IBAN en PHP ?
Non pour la clé de contrôle : une vingtaine de lignes suffisent, sans extension particulière, en calculant le modulo 97 par tranches de chiffres. Un package devient utile si vous voulez contrôler la longueur et la structure propres à chaque pays.
Un IBAN qui passe le contrôle existe-t-il forcément ?
Non. La clé détecte les fautes de frappe, pas les comptes inexistants ou clôturés. Seule la banque peut confirmer le compte, par exemple lors du premier prélèvement.
Comment savoir si un numéro de TVA intracommunautaire est réellement attribué ?
En l'interrogeant sur VIES, le service de la Commission européenne qui indique si un numéro est valide. Le calcul de la clé ne vérifie que la cohérence entre la clé et le SIREN.
Pourquoi ma règle regex lève une ErrorException ?
Le plus souvent, le motif n'a pas de délimiteurs (/.../) ou contient un | dans une règle écrite avec des barres verticales, ce qui coupe le motif. Écrivez les règles en tableau, ou utilisez Rule::regex().