Tutoriel : créer une API d’IMC avec Zana et un agent de codage IA
Pas à pas, d’un projet vide à une API testée qui fonctionne : l’agent inspecte l’application, propose un plan que vous relisez, génère la structure avec make:plan, écrit la logique métier et la prouve avec check et 31 tests.

Dans ce tutoriel, vous créez une petite API d’IMC (indice de masse corporelle) avec Zana et un agent de codage IA. L’API enregistre une taille, un poids et un âge facultatif, calcule l’IMC, le classe, le stocke et liste les mesures passées.
L’intérêt n’est pas la formule de l’IMC, qui tient en une ligne. C’est la façon de travailler. L’agent lit les règles du projet, propose un plan que vous relisez, génère la structure avec les commandes de Zana et écrit la logique métier. La commande check de Zana et les tests prouvent ensuite le résultat avant que quiconque le déclare terminé.
Nous avons suivi chaque étape ci-dessous avec Claude Code sur Zana 2.11, et les résultats affichés sont réels. Tout agent capable de lancer des commandes fonctionne de la même façon : Codex, Copilot, Cursor et d’autres. Vous pouvez aussi taper chaque commande vous-même ; rien ici ne dépend d’un fournisseur d’IA.
Ce qu’il vous faut
- PHP 8.5 avec l’extension
pdo_sqlite, et Composer. - Un agent de codage IA qui peut lire les fichiers et lancer des commandes dans votre projet. Il est facultatif, car chaque étape fonctionne aussi à la main.
- Une trentaine de minutes.
Étape 1 : créer le projet
Partez du squelette d’API de Zana. Il fournit une base de données, des comptes utilisateurs avec jetons d’accès, des erreurs au format problem JSON, des tests, et les instructions pour les agents IA.
composer create-project mj-zana/skeleton-api bmi-api
cd bmi-api
php bin/console migrate
Regardez les fichiers à la racine. Deux d’entre eux sont destinés à votre agent :
AGENTS.mdcontient les règles d’une application Zana. On y trouve la démarche à suivre, les couches d’un module et ce dont chacune peut dépendre, la façon d’enregistrer les services, ce qu’il ne faut pas faire et les commandes à lancer. Il mentionne la version installée (2.11) et renvoie à la documentation de cette version.CLAUDE.mdcontient une seule ligne,@AGENTS.md, et.github/copilot-instructions.mdrenvoie au même fichier. Quel que soit l’agent utilisé, il trouve les mêmes règles.
Avant de modifier quoi que ce soit, vérifiez que le projet est sain :
php bin/console check
✔ The container builds
✔ Deployment checks: BuiltInChecks
✔ Deployment checks: LogFilesCheck
✔ All 64 singletons can be created
✔ Architecture: 1 module(s), no violation
✔ MiddlewareCheck
✔ RouteHandlersCheck
✔ MigrationsCheck
✔ AgentsCheck
[OK] The application passes its checks.
AgentsCheck confirme que AGENTS.md correspond à la version de Zana installée. S’il devient obsolète après une mise à jour, check vous prévient.
Étape 2 : décrire ce que vous voulez
Ouvrez votre agent dans le dossier du projet et formulez la demande simplement :
Ajoute un module BMI à cette API. Un client envoie une taille en centimètres, un poids en kilogrammes et un âge facultatif. L’API calcule l’IMC (poids divisé par la taille en mètres au carré, arrondi à une décimale), le classe, enregistre la mesure et la renvoie. Un autre point d’accès liste les mesures les plus récentes, de la plus récente à la plus ancienne. Suis AGENTS.md. Montre-moi un plan avant d’écrire du code.
AGENTS.md demande à l’agent de reformuler la demande, d’inspecter l’application et de réutiliser l’existant avant d’ajouter quoi que ce soit. La dernière phrase de la demande rend l’étape de relecture explicite.
Étape 3 : laisser l’agent inspecter l’application
La première commande de l’agent est :
php bin/console inspect --format=json
inspect liste les modules, les services, les routes, les commandes console, les migrations, les paquets et les clés de configuration. Les valeurs de configuration ne sont jamais affichées : les secrets de votre .env restent hors de la conversation. Ici, l’agent voit un seul module, Accounts, et rien sur l’IMC. La nouvelle fonctionnalité aura donc son propre module.
Vous pouvez lancer la commande vous-même sans --format=json pour obtenir un tableau lisible, ou demander une seule section : php bin/console inspect routes.
Étape 4 : relire le plan
Au lieu d’improviser des fichiers, l’agent écrit un plan, bmi.plan.json, dans le format que comprend le générateur de Zana :
{
"schema": "zana.plan/1",
"module": "Bmi",
"entities": ["Measurement"],
"value_objects": [
{"name": "Height", "type": "float"},
{"name": "Weight", "type": "float"}
],
"repositories": ["Measurement"],
"features": [
{"name": "RecordMeasurement", "http": true, "fields": ["heightCm:float", "weightKg:float", "age:?int"]},
{"name": "ListMeasurements", "query": true, "http": true}
]
}
Il décrit un module avec une entité, deux objets-valeurs pour qu’une taille et un poids ne puissent jamais être invalides, un dépôt en base de données, et deux cas d’usage exposés en HTTP. age:?int rend l’âge facultatif.
L’agent vous montre ensuite ce que le plan ferait, sans rien écrire :
php bin/console make:plan bmi.plan.json --dry-run
make:module Bmi
* would create modules/Bmi/BmiModule.php
* would update config/modules.php
…
make:repository Bmi Measurement
* would create modules/Bmi/Infrastructure/Persistence/DatabaseMeasurementRepository.php
* would create modules/Bmi/Infrastructure/Persistence/Migrations/2026_11_02_182832_create_measurements_table.php
* would update modules/Bmi/BmiModule.php
make:feature Bmi RecordMeasurement --field=heightCm:float --field=weightKg:float --field=age:?int --http
* would create modules/Bmi/Application/Features/RecordMeasurement/RecordMeasurementCommand.php
* would create modules/Bmi/Application/Features/RecordMeasurement/RecordMeasurementHandler.php
* would create modules/Bmi/Tests/Application/RecordMeasurementHandlerTest.php
* would create modules/Bmi/Presentation/Api/ApiInput.php
* would create modules/Bmi/Presentation/Api/RecordMeasurementAction.php
* would create modules/Bmi/Tests/Presentation/RecordMeasurementApiTest.php
* would update modules/Bmi/BmiModule.php
…
Dry run; nothing was written.
C’est le moment de relire. Vérifiez le nom du module, les champs et leurs types, et si chaque fonctionnalité doit être une commande ou une requête. Changer une ligne du plan ne coûte rien à ce stade.
Le plan est aussi validé. Lors de notre premier essai, les champs étaient écrits height_cm:float, et le générateur a refusé le plan avant d’écrire le moindre fichier :
[ERROR] … The field "height_cm:float" must be name:type, with a camelCase name and the type string, int, float or bool (?type for an optional field).
L’agent a lu le message, renommé les champs en heightCm et weightKg, et relancé la simulation.
Étape 5 : générer la structure
Une fois le plan approuvé :
php bin/console make:plan bmi.plan.json
php bin/console migrate
php bin/console check
composer test
make:plan lance les mêmes commandes make: que vous pourriez taper vous-même, d’abord dans une copie temporaire. Rien n’est écrit si une seule étape échoue :
[OK] Ran 7 steps of the plan.
Le nouveau module est déjà câblé. Ses services sont enregistrés, ses handlers associés, ses routes chargées, et il figure dans config/modules.php. check passe avec Architecture: 2 module(s), no violation, et les tests tournent :
Tests: 23, Assertions: 79, Incomplete: 2.
Les deux tests incomplets sont ceux des handlers. Les handlers générés sont vides à dessein. Zana génère la structure, et les règles métier restent à écrire.
Étape 6 : écrire la logique métier
Donnez maintenant les règles à l’agent. Soyez précis, car c’est la partie qu’aucun générateur ne peut deviner :
Écris la logique métier du module Bmi.
- La taille est en centimètres, entre 30 et 300. Le poids est en kilogrammes, entre 1 et 700. L’âge, s’il est donné, est entre 2 et 120. Refuse toute autre valeur avec une erreur 422 de l’API, et protège aussi les objets-valeurs du domaine.
- IMC = poids ÷ (taille en mètres)², arrondi à une décimale.
- Classe les adultes selon les seuils de l’OMS : moins de 18,5 insuffisance pondérale, moins de 25 normal, moins de 30 surpoids, sinon obésité. Avant 20 ans, ne donne aucune catégorie, car les enfants s’évaluent avec les courbes d’IMC pour l’âge.
- Enregistre tous les champs, et renvoie la mesure avec un statut 201 quand elle est enregistrée.
- Remplace les tests incomplets par de vrais tests.
Voici ce que l’agent a écrit, et ce que vous devez relire.
Les objets-valeurs refusent les valeurs impossibles, quel que soit l’appelant :
final readonly class Height extends ValueObject
{
public float $centimetres;
public function __construct(float $centimetres)
{
if ($centimetres < 30 || $centimetres > 300) {
throw new DomainException('A height must be between 30 and 300 cm.');
}
$this->centimetres = $centimetres;
}
public function metres(): float
{
return $this->centimetres / 100;
}
}
La classification est une petite énumération du domaine :
enum Classification: string
{
case Underweight = 'underweight';
case Normal = 'normal';
case Overweight = 'overweight';
case Obesity = 'obesity';
public static function of(float $bmi, ?int $age): ?self
{
if ($age !== null && $age < 20) {
return null;
}
return match (true) {
$bmi < 18.5 => self::Underweight,
$bmi < 25.0 => self::Normal,
$bmi < 30.0 => self::Overweight,
default => self::Obesity,
};
}
}
La mesure calcule son IMC une seule fois, au moment de son enregistrement :
public static function record(Height $height, Weight $weight, ?int $age, \DateTimeImmutable $measuredAt): self
{
if ($age !== null && $age < 0) {
throw new DomainException('An age must not be negative.');
}
$bmi = round($weight->kilograms / ($height->metres() ** 2), 1);
return new self(MeasurementId::generate(), $height, $weight, $age, $bmi, Classification::of($bmi, $age), $measuredAt);
}
La commande déclare ses limites. Le ValidationBehavior du squelette les vérifie pour chaque appelant, qu’il s’agisse de l’API, d’une commande console ou d’une tâche en file d’attente, et l’API répond 422 avec les erreurs par champ :
final class RecordMeasurementCommand extends Command
{
public function __construct(
#[Range(min: 30, max: 300)]
public readonly float $heightCm,
#[Range(min: 1, max: 700)]
public readonly float $weightKg,
#[Range(min: 2, max: 120)]
public readonly ?int $age = null,
) {
parent::__construct();
}
}
Le handler relie les pièces :
#[Handles(RecordMeasurementCommand::class)]
final readonly class RecordMeasurementHandler
{
public function __construct(private MeasurementRepositoryInterface $measurements)
{
}
public function __invoke(RecordMeasurementCommand $command): array
{
$measurement = Measurement::record(
new Height($command->heightCm),
new Weight($command->weightKg),
$command->age,
new \DateTimeImmutable('now', new \DateTimeZone('UTC')),
);
$this->measurements->add($measurement);
return MeasurementData::from($measurement);
}
}
Le reste suit les commentaires laissés par le générateur dans les fichiers :
- la migration reçoit ses six colonnes ;
- les méthodes
row()ethydrate()du dépôt les associent, et une méthodelatest()liste les mesures les plus récentes ; ListMeasurementsHandlerrenvoie les 50 dernières ;- l’action d’API répond
201avec la mesure, au lieu du204généré.
Chaque couche reste là où AGENTS.md le prévoit. Le domaine ne sait rien du HTTP ni de la base de données, et l’API passe par le médiateur.
Étape 7 : des tests qui prouvent les règles
L’agent a remplacé les deux tests incomplets et ajouté des tests pour les règles :
- domaine :
170 cmet65 kgdonnent22.5, classé normal ; chaque seuil de l’OMS tombe dans la bonne catégorie (18.4,18.5,24.9,25,30) ; un enfant de 12 ans n’a pas de catégorie ; une taille de1.7, en mètres au lieu de centimètres, est refusée ; - application : le handler enregistre la mesure et renvoie son IMC, avec un dépôt en mémoire ; la liste commence par la plus récente ;
- API : une requête valide répond
201avec"bmi": 22.5; des champs manquants et une taille en mètres répondent422en nommant le champ.
Les tests d’API lancent migrate dans leur setUp(), sur la base SQLite en mémoire que le squelette configure pour les tests dans config/testing/. Ils ne touchent jamais à vos vraies données ni à votre .env.
Étape 8 : vérifier, tester et lire le résumé
php bin/console check
composer test
✔ The container builds
✔ Deployment checks: BuiltInChecks
✔ Deployment checks: LogFilesCheck
✔ All 65 singletons can be created
✔ Architecture: 2 module(s), no violation
✔ MiddlewareCheck
✔ RouteHandlersCheck
✔ MigrationsCheck
✔ AgentsCheck
[OK] The application passes its checks.
OK (31 tests, 110 assertions)
Si l’agent avait enfreint une règle, par exemple en faisant appeler le dépôt directement par l’action d’API, ou en oubliant d’enregistrer un service, check aurait échoué. Le constat aurait nommé le problème et donné une piste de correction. AGENTS.md demande à l’agent de corriger chaque erreur avant de continuer, et de ne jamais désactiver un contrôle pour le faire passer.
L’agent termine par un résumé : les fichiers créés, les décisions prises (comme l’absence de catégorie avant 20 ans, et les objets-valeurs pour la taille et le poids) et les commandes lancées avec leurs résultats. C’est ce résumé que vous relisez, avec les différences de code.
Étape 9 : lancer l’API
L’application met en cache son conteneur compilé dans var/cache. Vous avez modifié les constructeurs des handlers après leur génération : videz le cache avant de démarrer le serveur. AGENTS.md dit la même chose : ne jamais modifier les caches générés, les vider.
php bin/console cache:clear
composer serve
Enregistrez une mesure :
curl -X POST http://localhost:8000/api/bmi/record-measurement \
-H "Content-Type: application/json" \
-d '{"heightCm": 170, "weightKg": 65, "age": 30}'
{"data":{"id":"01a19d89-6e60-73fb-9e3e-170729503950","height_cm":170.0,"weight_kg":65.0,"age":30,"bmi":22.5,"classification":"normal","measured_at":"2026-11-02T18:33:12+00:00"}}
Un enfant obtient un IMC, mais pas de catégorie adulte :
{"data":{"id":"01a19d89-6ed6-7512-9a77-51b30950ca4a","height_cm":150.0,"weight_kg":45.0,"age":12,"bmi":20.0,"classification":null,"measured_at":"2026-11-02T18:33:12+00:00"}}
Une taille envoyée en mètres est refusée, avec la raison :
{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"The request contains invalid data.","errors":{"heightCm":["This value should be between 30 and 300."]}}
En développement, les réponses d’erreur incluent aussi l’exception et sa trace. En production, avec APP_ENV=production, ce n’est pas le cas.
Et l’historique, du plus récent au plus ancien :
curl http://localhost:8000/api/bmi/list-measurements
Ce que vous avez construit, et ce que vous avez fait
Le changement touche 32 fichiers : 31 dans modules/Bmi, plus une ligne dans config/modules.php. L’agent a généré la plupart d’entre eux avec make:plan, puis a écrit la logique métier et les tests. Vous avez fait trois choses qui comptent :
- Vous avez décrit la demande avec précision, y compris la règle médicale pour les moins de 20 ans.
- Vous avez relu le plan avant qu’un seul fichier ne soit écrit.
- Vous avez relu le résumé et les différences, en sachant que
checket 31 tests avaient déjà attrapé les erreurs mécaniques.
Ce partage est voulu. Les outils de Zana attrapent automatiquement les erreurs de câblage et d’architecture. Ils ne peuvent pas savoir si une règle d’IMC est médicalement juste, et ce jugement vous revient.
Pour aller plus loin
Essayez ces demandes avec la même démarche :
- Protéger les points d’accès : le squelette a déjà des utilisateurs et des jetons d’accès (
POST /api/tokens). Demandez à l’agent d’exiger une authentification sur les routes BMI, comme le faitShowProfileAction. - Ajouter des patients : un module
Patients, avec des mesures liées à un patient par un contrat entre modules. Les règles d’architecture garantissent que Bmi n’accède jamais aux détails internes de Patients. - Ajouter une page web : un formulaire et une page d’historique, avec
make:module --webet des templates.
Le guide complet se trouve dans la documentation de Zana (en anglais) : travailler avec les agents de codage IA.
L’IMC est un indicateur de dépistage, pas un diagnostic. Pour les enfants et les adolescents, utilisez les courbes d’IMC pour l’âge de l’OMS ou des CDC.
Si vous voulez de l’aide pour adopter cette façon de travailler dans votre équipe, parlons-en.


