Un float n'est pas un montant : les centimes
Temps 2 · séance 2 · ~35 min
Les slides du chapitre (PDF)PDF
- Expliquer pourquoi
0.1 + 0.2ne vaut pas0.3en PHP. - Reproduire dans le magasin un achat refusé alors que le solde suffit.
- Convertir les montants en centimes entiers, de la migration jusqu'à l'affichage.
- Écrire un test unitaire pour la mise en forme d'un montant.
- Activer l'extension
intlquandNumber::currency()la réclame.
Bob a 0,30 €, et le magasin refuse de lui vendre trois bonbons à 0,10 €. Le total est calculé avec un nombre qui ne vaut pas exactement un dixième.
1. Une erreur minuscule, répétée des milliers de fois
En janvier 1982, la Bourse de Vancouver lance un indice qui démarre à 1000. L'indice est recalculé à chaque transaction, environ 3 000 fois par jour, et chaque résultat est coupé à trois décimales au lieu d'être arrondi. Chaque coupure perd moins d'un millième de point, mais la perte se répète : l'indice baisse d'environ 25 points par mois sans que les cours aient baissé.
Le vendredi 25 novembre 1983, l'indice ferme à 524,811. Après un week-end de recalcul, il ouvre le lundi à 1098,892. L'erreur venait d'une règle d'arrondi, pas de PHP ni d'un type de nombre. Elle montre ce qui guette tout programme qui manipule de l'argent : une erreur invisible sur une opération devient visible après des milliers d'opérations.
2. 0.1 + 0.2 dans Tinker
Ouvrez un terminal dans votre copie du Tamagotchi et demandez à PHP une addition simple :
php artisan tinker --execute="echo 0.1 + 0.2;"
0.3
Le résultat paraît juste. Demandez maintenant une écriture plus précise avec var_dump(), qui affiche le type et assez de chiffres pour distinguer deux floats différents :
php artisan tinker --execute="var_dump(0.1 + 0.2, 0.1 * 3, 0.1 + 0.2 == 0.3);"
float(0.30000000000000004)
float(0.30000000000000004)
bool(false)
0.1 + 0.2 ne vaut pas 0.3, et la comparaison le confirme. Avec le réglage precision de PHP, qui vaut 14, echo affiche au plus quatorze chiffres significatifs et cache l'écart. var_dump() suit un autre réglage, serialize_precision, et affiche ici assez de chiffres pour distinguer le résultat de 0.3.
Un nombre à virgule s'appelle en PHP un float. PHP le range sur 64 bits, en binaire, avec un nombre fixe de chiffres. En décimal, un tiers s'écrit 0,3333… sans fin, et vous devez vous arrêter quelque part. En binaire, un dixième a le même défaut : il s'écrit 0,000110011…, avec le motif 0011 qui se répète sans fin. PHP arrondit 0,1 au nombre binaire le plus proche qu'un float sait écrire. printf() en montre vingt décimales :
php artisan tinker --execute="printf('%.20f', 0.1);"
0.10000000000000000555
Le 0.1 de PHP vaut donc un tout petit peu plus que 0,1. 0.2 et 0.3 ont aussi leur petit écart, et ces écarts ne s'annulent pas toujours dans un calcul.
3. Trois bonbons pour Bob
Dans le Tamagotchi, chaque montant est un float : le solde users.balance, le prix items.price, le total d'un achat purchases.total et la valeur d'un code cadeau gift_codes.amount. La migration des articles le déclare ainsi :
$table->float('price');
Voici le calcul de l'achat, dans app/Http/Controllers/PurchaseController.php, coupé à l'essentiel :
$quantity = $request->integer('quantity', 1);
$price = $request->float('price');
$total = $price * $quantity;
// …
if ($user->balance < $total) {
return back()->withErrors(['quantity' => 'Votre solde est insuffisant.']);
}
Le chapitre 2.08 s'occupe du prix qui vient de la requête. Ici, regardez seulement la multiplication et la comparaison.
Le seeder donne 0,30 € à Bob. Bob se connecte avec bob@example.com et le mot de passe password, ouvre « 04 Magasin », tape 3 dans la quantité du Bonbon, à 0,10 €, et clique sur « Acheter ». Dans l'onglet Réseau, la requête et la réponse sont celles d'un achat ordinaire :
POST /magasin/1/acheter
{"price":"0.1","creature_id":"5","quantity":"3"}
Réponse : 302, retour à /magasin
Le magasin répond « Votre solde est insuffisant. ». L'en-tête affiche pourtant « SOLDE 0.3 € ».

Le contrôleur a calculé 0.1 * 3, soit 0.30000000000000004, et 0.3 < 0.30000000000000004 est vrai. Bob achète ensuite les bonbons un par un. Les trois achats passent, et son solde tombe à 0 €.
La capture montre un second défaut. Les montants s'écrivent 0.3 €, 3.2 € ou 19.9 €, avec un point et sans le zéro final. L'en-tête colle le signe euro derrière le nombre reçu, dans resources/js/components/AppHeader.vue :
{{ $page.props.auth.user.balance }} €
Ce que coûte l'erreur
Ici, personne ne vole personne. Le joueur dont le solde égale le total au centime près se voit refuser un achat qu'il peut payer. Un float ne garantit aucun montant au centime près, ni dans un calcul ni en base.
Le développeur ne l'a pas vu, car tout semble juste partout où il regarde. echo affiche 0.3, et le JSON de la page porte 0.3. Après un achat d'un bonbon, PHP calcule un solde de 0.19999999999999998, mais le solde relu en base vaut 0.2. Laravel passe le solde à PDO, l'extension de PHP qui parle à la base, et PDO l'écrit en texte avec quatorze chiffres, comme echo : la requête envoie 0.2. L'écart est caché au moment d'enregistrer, et il ne se voit que dans le cas limite où le total égale le solde.
4. À vous : refaites le calcul (5 min)
Sur votre copie, sur votre machine.
- Lancez
php artisan migrate:fresh --seed, puis les trois commandes Tinker de la section 2. - Connectez-vous comme Bob,
bob@example.com, mot de passepassword, et ouvrez « 04 Magasin ». - Achetez trois bonbons d'un coup, et relevez le message sous la quantité.
- Achetez-les un par un, et regardez le solde de l'en-tête.
- Lancez
php artisan test --filter=Flaw07et lisez les noms des quatre tests rouges.
Bob a maintenant 0 €. Relancez php artisan migrate:fresh --seed pour retrouver les montants du seeder avant de commencer la correction.
5. La correction : compter en centimes
Un montant en euros contient au plus deux décimales. En centimes, il n'en a plus : 0,10 € devient 10, 0,30 € devient 30. Un nombre entier, un int en PHP, se range et se calcule sans écart :
php artisan tinker --execute="var_dump(10 * 3, 10 * 3 === 30);"
int(30)
bool(true)
La règle tient en trois lignes. Les montants sont rangés en centimes, dans des colonnes entières. Tous les calculs se font en centimes. La conversion en euros n'a lieu qu'au moment d'afficher, sur le serveur, qui envoie à la page une chaîne déjà écrite.
Ce que vous devez obtenir :
| Avant | Après |
|---|---|
users.balance, float |
users.balance_cents, entier |
items.price, float |
items.price_cents, entier |
purchases.total, float |
purchases.total_cents, entier |
gift_codes.amount, float |
gift_codes.amount_cents, entier |
la page affiche 0.3 € |
la page affiche 0,30 € |
Le suffixe _cents dit l'unité dans le nom de la colonne. Personne ne peut plus croire que 30 veut dire trente euros. La correction touche une quinzaine de fichiers. Avancez par étapes, et relancez php artisan test --filter=Flaw07 après chacune.
Étape 1 : la migration
Votre base contient déjà des joueurs, des articles et des achats. Une nouvelle migration doit ajouter les colonnes en centimes, y recopier les montants, puis supprimer les anciennes colonnes. Créez-la :
php artisan make:migration convert_amounts_to_cents
INFO Migration […\database\migrations\…_convert_amounts_to_cents.php] created successfully.
Le fichier généré contient Schema::table('cents', …). Laravel a deviné une table à partir de la fin du nom, to_cents. Il n'existe pas de table cents : remplacez ce squelette. La migration traite les quatre tables de la même façon. Voici la première :
Schema::table('users', fn (Blueprint $table) => $table->integer('balance_cents')->default(5000));
DB::table('users')->update(['balance_cents' => DB::raw('ROUND(balance * 100)')]);
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('balance'));
La première ligne ajoute la colonne entière, avec 5000 centimes par défaut, les 50 € d'un nouveau joueur. La deuxième recopie chaque solde multiplié par 100. DB::raw() passe une expression SQL telle quelle, et la base calcule elle-même, ligne par ligne. La troisième supprime l'ancienne colonne.
ROUND n'est pas là par prudence. Multiplier un float par 100 recrée un écart, et convertir en entier coupe au lieu d'arrondir :
php artisan tinker --execute="var_dump(0.29 * 100, (int) (0.29 * 100), round(0.29 * 100));"
float(28.999999999999996)
int(28)
float(29)
Sans arrondi, 0,29 € deviendrait 28 centimes. SQLite se comporte de même : CAST(0.29 * 100 AS INTEGER) y vaut 28, ROUND(0.29 * 100) vaut 29. C'est l'erreur de Vancouver, en plus petit.
La méthode down() fait le chemin inverse, balance_cents / 100.0 vers une colonne float. Avant de lancer la migration, regardez les montants dans votre base :
php artisan tinker --execute="echo json_encode(DB::table('users')->pluck('balance', 'name'), JSON_UNESCAPED_UNICODE);"
{"Alice":120,"Mallory":5,"Bob":0.3}
Lancez la migration :
php artisan migrate
INFO Running migrations.
…_convert_amounts_to_cents .. 72.32ms DONE
Relancez la commande Tinker avec balance_cents à la place de balance :
{"Alice":12000,"Mallory":500,"Bob":30}
Les prix suivent le même chemin : le Coussin doré passe de 19.9 à 1990.
Étape 2 : les modèles, les fabriques et le seeder
Dans les quatre modèles, le montant change de nom dans le commentaire @property et dans casts(), avec le cast integer au lieu de float. Dans Item, Purchase et GiftCode, renommez aussi le champ dans #[Fillable]. Dans User, le solde n'est plus dans cette liste depuis le chapitre 2.03, et il n'y revient pas. Les fabriques et le seeder écrivent les montants en centimes : 'balance' => 120 devient 'balance_cents' => 12000.
La fabrique des joueurs donne 50 € à chaque nouveau joueur : 'balance_cents' => 5000. Après cette étape, deux tests de la faille passent :
✓ amounts are stored in cents
✓ a new player starts with 50 €
⨯ three sweets at 0,10 € cost exactly 0,30 €
⨯ the shop displays prices in euros
…
Tests: 2 failed, 2 passed (19 assertions)
Étape 3 : les contrôleurs
PurchaseController et GiftCodeController calculent encore avec balance, price, total et amount. Cherchez ces mots dans tout le projet avec la recherche de votre éditeur, et passez chaque calcul en centimes. Le formulaire du magasin envoie son prix dans un champ caché : il doit s'appeler price_cents et porter item.price_cents, sinon le contrôleur lit 0 et l'article devient gratuit. Le test des trois bonbons passe :
✓ three sweets at 0,10 € cost exactly 0,30 €
…
Tests: 1 failed, 3 passed (20 assertions)
Étape 4 : un test unitaire pour Money::format()
Il reste l'affichage, le second défaut vu à la section 3 : la page écrit 0.3 € au lieu de 0,30 €. Les montants sont maintenant des centimes, et une seule méthode les mettra en forme pour toutes les pages : App\Support\Money::format(). Elle reçoit des centimes et rend une chaîne : 1050 donne 10,50 €. À l'étape 5, le magasin et l'en-tête l'appellent. Si elle se trompe, chaque montant affiché est faux. Vous écrivez donc d'abord son test, qui fixe ce qu'elle doit rendre, puis la méthode.
Au chapitre 1.17, vos tests de tests/Feature envoyaient une requête à l'application et lisaient la réponse. Un test unitaire vérifie une seule méthode, sans requête ni base de données : il l'appelle avec une valeur et compare le résultat. Dans tests/Pest.php, TestCase et RefreshDatabase ne s'appliquent qu'au dossier Feature. Un test de tests/Unit ne démarre donc pas Laravel, et il tourne en quelques millisecondes.
php artisan make:test MoneyTest --unit
php artisan make:class Support/Money
La première commande crée tests/Unit/MoneyTest.php, la seconde app/Support/Money.php avec un constructeur vide. Remplacez le test généré par celui-ci :
<?php
use App\Support\Money;
test('cents are displayed as euros', function (int $cents, string $displayed) {
expect(Money::format($cents))->toBe($displayed);
})->with([
[1050, '10,50 €'],
[10, '0,10 €'],
[0, '0,00 €'],
[123456, '1 234,56 €'],
]);
with() donne au test une liste de cas. Pest lance la fonction une fois par ligne, avec les deux valeurs en arguments, et affiche chaque cas comme un test. toBe() compare avec === : même type, mêmes caractères. Lancez-le :
php artisan test tests/Unit/MoneyTest.php
FAIL Tests\Unit\MoneyTest
⨯ cents are displayed as euros with (1050, '10,50 €')
…
Call to undefined method App\Support\Money::format()
Le test est rouge, car la méthode n'existe pas. Laravel fournit Number::currency(), de la classe Illuminate\Support\Number, qui écrit un nombre comme un montant dans la langue choisie. in: donne la devise, locale: la langue. Écrivez la méthode dans app/Support/Money.php :
<?php
namespace App\Support;
use Illuminate\Support\Number;
class Money
{
/**
* Turn a whole number of cents into an amount to display: 1050 gives "10,50 €".
*/
public static function format(int $cents): string
{
return (string) Number::currency($cents / 100, in: 'EUR', locale: 'fr');
}
}
La division par 100 rend un float, mais seulement pour l'affichage, et Number::currency() arrondit à deux décimales. Aucun calcul ne réutilise ce résultat. Number::currency() peut aussi rendre false en cas d'échec. (string) transforme ce false en chaîne vide : le type de retour est respecté, mais un échec afficherait un montant vide, sans erreur.
Astuce
« The "intl" PHP extension is required to use the [currency] method. » veut dire que votre PHP n'a pas l'extension intl, la bibliothèque qui connaît les règles d'écriture des nombres de chaque langue. Le test échoue avec ce message, et, dans le navigateur, toute page d'un joueur connecté rend une erreur 500, car l'en-tête affiche le solde.
- Vérifiez :
php -m | grep intl, ouphp -m | Select-String intldans PowerShell. Rien ne s'affiche si l'extension manque. - Trouvez le fichier de configuration avec
php --ini, à la ligneLoaded Configuration File. Dans Laragon,Menu, puisPHP, puisphp.iniouvre le même fichier. - Cherchez la ligne
;extension=intlet retirez le point-virgule du début :extension=intl. - Arrêtez puis relancez Laragon ou Herd, et
composer run dev.php -mdoit maintenant listerintl.
Relancez le test. Il reste rouge, et la sortie est déroutante :
FAILED Tests\Unit\MoneyTest > cents are displayed as euros with (1050, '10,50 €')
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'10,50 €'
+'10,50 €'
…
Tests: 4 failed (4 assertions)
Pest compare la valeur attendue, écrite dans le test, à la valeur obtenue, rendue par Money::format(). À l'écran, les deux s'écrivent 10,50 €. Pourtant toBe() les dit différentes : un caractère diffère, et il ne se voit pas. Pour le voir, demandez à json_encode(), qui écrit chaque caractère spécial avec son code :
php artisan tinker --execute="echo json_encode(App\Support\Money::format(123456));"
"1\u202f234,56\u00a0\u20ac"
La différence est dans les espaces. Votre clavier tape une espace ordinaire, de code U+0020. Number::currency() en met d'autres. Avant le signe €, il met une espace insécable, de code U+00A0 : un caractère qui ressemble à une espace, mais qui interdit de couper la ligne à cet endroit. Le montant et son signe € restent ainsi toujours sur la même ligne. Entre les milliers, il met une espace fine insécable, U+202F, plus étroite, qui empêche aussi la coupure. Les règles d'écriture du français demandent ces espaces, et l'extension intl les applique. Pour toBe(), ce sont trois caractères différents.
À l'écran, rien ne change : un montant s'affiche bien, comme 10,50 €, et l'espace insécable y fait son travail. Le piège ne concerne que la comparaison stricte d'un test.
Ne touchez pas à Money::format() : c'est la valeur attendue du test qui doit changer. Dans la valeur attendue, écrivez le caractère par son code, \u{00A0} avant le signe €, et \u{202F} entre les milliers, entre guillemets doubles. Dans une chaîne PHP entre guillemets doubles, \u{00A0} désigne le caractère de code U+00A0. Entre guillemets simples, PHP laisse les huit caractères tels quels. Quand un test de montant reste rouge sans différence visible, json_encode() vous montre quel caractère la fonction a rendu. Le test final, tests/Unit/MoneyTest.php :
<?php
use App\Support\Money;
test('cents are displayed as euros', function (int $cents, string $displayed) {
expect(Money::format($cents))->toBe($displayed);
})->with([
[1050, "10,50\u{00A0}€"],
[10, "0,10\u{00A0}€"],
[0, "0,00\u{00A0}€"],
[123456, "1\u{202F}234,56\u{00A0}€"],
]);
PASS Tests\Unit\MoneyTest
✓ cents are displayed as euros with (1050, '10,50 €')
✓ cents are displayed as euros with (10, '0,10 €')
✓ cents are displayed as euros with (0, '0,00 €')
✓ cents are displayed as euros with (123456, '1 234,56 €')
Tests: 4 passed (4 assertions)
Étape 5 : le serveur envoie des montants écrits
La page ne fait plus aucun calcul ni aucune mise en forme. Elle affiche une chaîne reçue en prop.
- Dans
ShopController@index, chaque article devient un tableau explicite, comme au chapitre 2.04, avec une clépricequi vautMoney::format($item->price_cents). - Dans
app/Http/Middleware/HandleInertiaRequests.php, la cléauthreçoitbalance, le solde déjà écrit, ounullpour un visiteur. - L'en-tête affiche
$page.props.auth.balance, et le magasinitem.price, sans ajouter le signe €.
Cherchez dix minutes, puis ouvrez la correction.
Lancez php artisan migrate:fresh --seed pour repartir du seeder écrit en centimes, puis npm run build ou composer run dev pour la page.
6. La preuve
Avant la correction, les quatre tests de la faille sont rouges :
php artisan test --filter=Flaw07
FAIL Tests\Feature\Flaws\Flaw07CentsTest
⨯ amounts are stored in cents 0.47s
⨯ a new player starts with 50 € 0.06s
⨯ three sweets at 0,10 € cost exactly 0,30 € 0.03s
⨯ the shop displays prices in euros 0.03s
────────────────────────────────…
FAILED Tests\Feature\Flaws\Flaw07CentsTest > amounts are stored in cents
Failed asserting that false is true.
…
FAILED Tests\Feature\Flaws\Flaw07CentsTest > three sweets at 0,10 € cost exactly 0,30 € QueryException
SQLSTATE[HY000]: General error: 1 table users has no column named balance_cents …
…
Tests: 4 failed (2 assertions)
Les tests de la séance 2 sont écrits en centimes. Tant que la colonne balance_cents n'existe pas, ceux des chapitres 2.08 et 2.09 échouent aussi sur ce message. Après la correction :
PASS Tests\Feature\Flaws\Flaw07CentsTest
✓ amounts are stored in cents 0.51s
✓ a new player starts with 50 € 0.06s
✓ three sweets at 0,10 € cost exactly 0,30 € 0.09s
✓ the shop displays prices in euros 0.09s
Tests: 4 passed (20 assertions)
Lancez ensuite toute la suite avec php artisan test. Le tableau de bord passe de 14 tests rouges à 8, et vos quatre cas de MoneyTest s'ajoutent aux tests verts :
Tests: 8 failed, 81 passed (347 assertions)
Rejouez enfin l'achat de Bob. Trois bonbons d'un coup passent, la notification « Achat effectué pour Noisette. » s'affiche, et l'en-tête dit « SOLDE 0,00 € ».

7. La règle
Recopiez cette phrase dans votre carnet des failles :
Un montant se range et se calcule en centimes entiers, et le serveur ne le met en forme qu'au moment de l'afficher.
Votre portfolio ne manipule pas d'argent. Notez quand même où vous rangeriez un prix, et le nom de sa colonne. Le chapitre 2.08 s'occupe du prix lui-même : d'où le serveur doit-il le lire ?
À retenir
- Un float range 0,1 en binaire avec un petit écart, et
0.1 + 0.2 == 0.3rendfalseen PHP. echoaffiche au plus quatorze chiffres significatifs et cache l'écart, alors quevar_dump()en affiche assez pour le montrer.- Un montant se range dans une colonne entière en centimes, avec
_centsdans son nom, et tous les calculs restent en entiers. - Une migration qui convertit des euros en centimes utilise
ROUND(x * 100), car la conversion en entier coupe au lieu d'arrondir. Number::currency()demande l'extensionintlet écrit une espace insécable avant le signe €, qu'un test écrit\u{00A0}entre guillemets doubles.
Pour aller plus loin
- « Vancouver Stock Exchange », Wikipédia (en anglais) : https://en.wikipedia.org/wiki/Vancouver_Stock_Exchange
- « Report from AAAI-86 », Alan Wexelblat, The RISKS Digest, volume 3, numéro 41, 23 août 1986, qui cite le Toronto Star du 29 novembre 1983 : https://catless.ncl.ac.uk/Risks/3/41
- « Floating-point numbers », manuel de PHP : https://www.php.net/manual/en/language.types.float.php
- « Helpers », section « Number::currency() », documentation de Laravel 13.x : https://laravel.com/docs/13.x/helpers