Introduction
Depuis le début de ce blog, je n’ai cessé de parler de Symfony, de PHP, d’IA, de projets qui tournent en local. Mais dans le fond, ce que j’ai toujours voulu faire, c’est de l’application native. Tout simplement parce que je vois ce domaine comme l’aboutissement de mon expérience de développeur web Symfony.
J’y suis, avec Kejja et son app iOS. Et le premier constat est tombé côté serveur : faire une API Symfony pour un site web et en faire une pour une app, ça n’a rien à voir. Il y a des pièges, et le choix du framework n’en évite aucun.
Kejja, c’est quoi ?
Une web app Symfony fullstack, qui met en avant les briques développées par la core team, Symfony UX en tête. Le but est simple, et il doit résoudre un souci majeur : le temps qu’on perd à choisir un film ou une série parmi toutes les plateformes auxquelles on est abonné.
C’est encore en bêta, dans un petit groupe de proches. Mais l’idée finale, c’est bel et bien une app native. Et pas en React Native : je n’aime pas l’idée de dépendre d’un framework rattaché à Meta.
API pour une application mobile ou pour le web : pas le même travail
Ici, les seules contraintes sont celles que je me fixe moi-même. Je peux donc partir de mes besoins, et de mon point de vue, API Platform n’aurait pas apporté grand-chose.
Côté web, Kejja reste une app Symfony fullstack : pas d’API à exposer, donc pas de raison d’installer API Platform pour le moment. Côté mobile, il n’y aura dans un premier temps qu’une app iOS, pas d’Android. On reste sur un monolithe : toute la partie mobile vit dans src/Mobile/, totalement isolée du web, tout en réutilisant les briques dont elle a besoin.
Et quand on regarde ce qu’il y a dedans, près de la moitié du dossier est dédiée à la sécurité — 35 fichiers sur 74 :
Security/
├── AccountDeletion/
│ ├── DeletionConfirmation.php
│ └── MobileAccountDeletionService.php
├── ApiAuthenticationFailureHandler.php
├── ApiEntryPoint.php
├── ApiTokenGenerator.php
├── ApiTokenHandler.php
├── ApiTokenHasher.php
├── Exception/
│ ├── EmailAlreadyUsedException.php
│ └── RegistrationEmailDeliveryException.php
├── Identity/
│ ├── Apple/
│ │ ├── AppleClientSecretFactory.php
│ │ ├── AppleIdTokenClaims.php
│ │ ├── AppleIdTokenVerifier.php
│ │ ├── AppleJwksProvider.php
│ │ ├── AppleTokenRevoker.php
│ │ └── Exception/
│ ├── Google/
│ │ ├── GoogleIdTokenClaims.php
│ │ ├── GoogleIdTokenVerifier.php
│ │ ├── GoogleJwksProvider.php
│ │ └── Exception/
│ ├── Exception/
│ ├── AppleIdentityProvider.php
│ ├── GoogleIdentityProvider.php
│ ├── PasswordIdentityProvider.php
│ ├── IdentityProviderInterface.php
│ ├── IdentityProviderRegistry.php
│ ├── IdentityResult.php
│ └── IdTokenNonceGuard.php
├── LoginRateLimiter.php
├── RefreshRateLimiter.php
├── MobileAuthenticationService.php
├── MobilePasswordResetService.php
└── MobileRegistrationService.phpPrenons la suppression de compte. Apple impose qu’elle soit possible depuis l’app, et si l’utilisateur s’est inscrit avec « Sign in with Apple », il faut appeler directement l’API d’Apple pour révoquer ses jetons. Sur le web, rien ne m’aurait demandé ça.
Tout ceci pour vous expliquer que oui, c’est un choix réfléchi.
Le serveur local en V1
Pour le serveur local, rien de bien complexe : Xcode côté app, OrbStack pour faire tourner l’API et FrankenPHP. Inutile de faire compliqué. Sauf qu’iOS, via App Transport Security, refuse par défaut les connexions qui ne passent pas en HTTPS avec un certificat de confiance. On pourrait ouvrir une exception pour le réseau local, mais je préfère rester au plus près de la prod.
J’en profite donc pour générer en local un certificat valide avec mkcert, que je monte dans le Caddy de FrankenPHP. Dernière étape, celle qu’on oublie : faire confiance à l’autorité mkcert côté iOS, dans le simulateur (xcrun simctl keychain booted add-root-cert "$(mkcert -CAROOT)/rootCA.pem") comme sur l’iPhone. Résultat : une vraie connexion HTTPS en local, fonctionnelle.
Côté conteneurs, une stack au plus proche de la prod, avec Mailpit pour intercepter les mails en local :
- FrankenPHP
- PostgreSQL
- Redis
- Mailpit
Et c’est justement ce qui alourdit le dev local. Xcode, OrbStack, Claude Code et un IDE ouverts en même temps : presque 18 Go de RAM. Il faut donc prévoir une belle machine bien équipé pour avoir la meilleure DX possible.
L’authentification : SSO et classique
Comme dit plus haut, c’est d’abord la partie web qui définit les usages de la partie mobile. Si le web propose de se connecter avec Google, l’app suit. Et là, Apple a son mot à dire : une app qui propose une connexion via un service tiers doit aussi offrir une option équivalente, respectueuse de la vie privée. En pratique, c’est Sign in with Apple.
Pour le reste, un identifiant et un mot de passe classiques pour ceux qui le souhaitent. Et pour garantir la sécurité, une 2FA par mail : en 2026, on se doit d’implémenter cela. C’est un compromis, et je l’assume. Si la boîte mail est compromise, la réinitialisation du mot de passe et le code 2FA tombent ensemble. L’étape suivante, ce sont les passkeys. Mais elles demandent de bien comprendre les mécaniques avant de s’y lancer : WebAuthn côté serveur, le domaine déclaré comme relying party, et côté iOS l’association entre l’app et le domaine. Ce n’est pas une case à cocher.
La partie iOS native
Pour cela, il faut obligatoirement un Mac avec Xcode. Et je dois dire que le Mac doit quand même être costaud : si vous êtes dans le même cas que moi, entre la VM Docker, Xcode et le simulateur, les 18 Go de RAM partent vite. Je vous conseille un Mac avec 32 Go de RAM minimum et une puce M2 Pro.
Mon but ici est d’apprendre Swift, pour être capable de déployer n’importe quelle bonne idée sur un Mac ou un iPhone, avec mes propres règles. Kejja est le bon candidat : il a des vues simples, d’autres avec de la transaction, et surtout, le premier client de l’app, ce sera moi.
À terme, l’idée est d’utiliser toute la puissance de la plateforme : Core ML, ou encore les modèles embarqués d’Apple Intelligence via le framework Foundation Models, pour proposer en local, sans récupération de données, le meilleur système de recommandation du marché.
Un DTO par réponse, un toArray() écrit à la main
Côté réponses, src/Mobile/Dto/ contient 25 classes, toutes en lecture seule, une par forme de réponse. Aucune entité Doctrine ne sort directement de l’API.
Dto/
├── AccountStateResponse.php
├── MeResponse.php
├── RecommendationItemResponse.php
├── RecommendationQuotaResponse.php
├── RecommendationsResponse.php
├── TokenPairResponse.php
├── TwoFactorChallengeResponse.php
├── WatchlistResponse.php
└── … 17 autresChaque DTO se construit par un constructeur nommé, MeResponse::fromUser(), AccountStateResponse::fromUser(), qui assemble la réponse à partir du domaine. Et chacun expose un toArray() écrit à la main. Pas de Serializer, pas de groupes de normalisation.
Ça peut sembler daté. C’est volontaire. Sur le web, si je retire un champ, je redéploie le front en même temps et personne ne s’en aperçoit. Une app iOS publiée reste installée des mois sur des téléphones que je ne contrôle pas, et elle attend toujours la même forme de JSON. Retirer un champ doit donc être un geste visible en revue, jamais l’effet de bord d’un refactor sur une entité. Avec un toArray() explicite, chaque modification du contrat apparaît noir sur blanc dans le diff.
La route /me montre ce que ça donne dans un contrôleur :
#[Route('/me', name: 'api_mobile_me', methods: ['GET'])]
public function me(#[CurrentUser] User $user): JsonResponse
{
// Lu une seule fois : `isMoodRequired()` et `today_mood` en ont tous deux
// besoin, un second appel referait la même requête pour rien.
$todayMood = $this->moodRecommendationService->getTodayMood($user);
$state = AccountStateResponse::fromUser($user, $this->isMoodRequired($user, $todayMood), $todayMood);
return new JsonResponse(MeResponse::fromUser($user, $state)->toArray());
}L’humeur du jour est lue une seule fois, parce que deux calculs en dépendent : savoir si l’app doit la demander, et la renvoyer dans la réponse. Le contrôleur assemble, le DTO décide de la forme.
Reste l’entrée. Il n’y a pas de DTO côté requête : chaque service reçoit le corps JSON sous forme de tableau et vérifie lui-même chaque champ, présence et type, avant d’y toucher. Un champ absent ou mal typé, et la réponse repart avec l’un des codes d’erreur stables de l’API.
API Platform sera utile pour la V2
En V1, l’app et le web n’exposent pas la même chose, au sens strict. Il ne m’a pas semblé utile de faire appel à tout un framework pour une poignée de routes qui m’auraient demandé, je pense, bien plus de travail que ces quelques tableaux JSON.
Autre chose : je suis seul développeur sur le projet. Pas de contrainte de travail en équipe, pas de conventions à partager. Plus tard, si le projet grossit, passer à API Platform deviendra nécessaire, autant pour ce qu’il apporte à une équipe que pour les garde-fous qu’il pose sur les workflows.
Mais il réglera l’équipe et les conventions, pas le contrat avec l’app. Celui-là restera explicite : API Platform sait exposer des DTO de sortie via ses state providers, et c’est ainsi que je l’intégrerai. La version 5.0, sortie le 17 septembre, en donne d’ailleurs une bonne illustration. Elle exige Symfony 7.4 au minimum, supprime les APIs dépréciées, et une erreur de dénormalisation sur un champ contraint renvoie désormais un 422 au lieu d’un 400. Côté web, on met à jour et on redéploie. Côté app, un binaire qui attendait un 400 ne le sait pas.
Temps réel, oui, mais surtout précalculé
Bien sûr que le temps réel est possible, et merci à Mercure de le proposer presque par défaut dans l’écosystème FrankenPHP. Mais ça reste lourd, et on n’est pas du tout sur une commande ou un PDF à générer. Ici, on parle de recommandations cross-SVOD, qui dépendent de l’âge, de l’humeur du jour, le tout corrélé avec les métriques de base comme les acteurs favoris.
La plupart des actions sont donc précalculées, ce qui allège d’autant le calcul en temps réel. Côté API, GET /recommendationssert la sélection du jour depuis un cache journalier, la même que sur le web : l’app et le site affichent les mêmes titres, et un appel mobile consomme le même quota.
#[Route('/recommendations', name: 'api_mobile_recommendations', methods: ['GET'])]
public function list(Request $request, #[CurrentUser] User $user): JsonResponse
{
$limit = $this->resolveLimit($request);
if (!$this->quotaChecker->canRequestRecommendations($user)) {
return $this->quotaExceededResponse($user);
}
try {
// Aucun `recommendationType` : voir la note de classe.
$result = $this->recommendationService->getRecommendations($user, $limit);
} catch (\Throwable $e) {
return $this->unavailableResponse($user, $e);
}
return new JsonResponse($this->buildRecommendationsResponse($user, $result)->toArray());
}Trois recommandations, et pas une de plus : c’est la règle produit. Le paramètre ?limit= permet d’en demander moins, jamais plus, ce qui borne au passage le coût de chaque appel. Et si toutes les stratégies échouent, l’app reçoit un 503 avec un code stable plutôt qu’un 500 muet : elle peut afficher son état d’erreur et proposer de réessayer.
Le vrai calcul à la demande arrive quand l’utilisateur change d’humeur. Là, POST /recommendations/mood pose l’humeur et renvoie la nouvelle sélection en un seul aller-retour : l’app quitte l’écran d’humeur avec ses recommandations en main, sans second appel.
L’app native, elle, ne calcule rien pour le moment. Mais j’espère pouvoir y ajouter, via Foundation Models, une couche qui affine localement les recommandations reçues, tout en préservant la vie privée des utilisateurs.
Les garde-fous qu’on n’écrit pas pour un front web
Une app installée survit au déploiement. Le contrat devient une frontière réelle, et il faut la protéger ailleurs que dans la bonne volonté du développeur.
Un interrupteur. Un subscriber passe avant le routeur et le firewall, et permet de livrer l’API en prod sans qu’elle réponde. Quand elle est éteinte, elle rend un 404, pas un 403 : une API désactivée ne révèle pas qu’elle existe.
Des firewalls stateless, placés avant main. C’est le piège numéro un. main n’a pas de pattern, il attrape tout ; placés après lui, les appels mobiles retomberaient sur l’authentification par session, et l’app recevrait une redirection suivie de HTML.
Des routes publiques énumérées une à une. Seules les routes qui doivent l’être échappent à l’authentification, listées explicitement. Un motif large sur /auth aurait aussi rendu publique la déconnexion, qui exige pourtant un jeton.
Une limitation sur plusieurs axes, en fenêtre glissante : par IP, par compte pour résister à un pool d’adresses, et un axe séparé pour le rafraîchissement des jetons, afin qu’il ne consomme pas le quota de connexion. Une fenêtre fixe laisserait passer le double de requêtes à cheval sur sa frontière.
Des DTO qui citent l’app nommément. Côté serveur, une série est un tv_show ; côté contrat, c’est tv. La traduction est écrite dans le DTO, avec un commentaire, là où on risquerait de la casser.
Et des tests qui figent le comportement. Une suite dédiée vérifie que l’API ne sort jamais du JSON : erreurs d’authentification sans redirection, aucun cookie de session émis, route inconnue ou mauvaise méthode en JSON, toutes les causes de rejet indiscernables, routes web non affectées. Elle tourne en CI sur chaque PR.
Ce que je referais, et le produit
La principale différence entre Kejja et un POC, c’est évidemment que Kejja est un produit. Et ça change tout, vraiment tout.
Entre le concept de base, l’infra et le dev pur, c’est long, stimulant, et très instructif au quotidien.
Si je devais refaire une chose essentielle : ne pas prendre une idée et se jeter dans le concept jusqu’au bout. Au contraire, il faut d’abord bien définir le besoin, comprendre les impacts sur l’infra, et seulement ensuite développer. Aujourd’hui encore, deux trous le rappellent : aucun schéma opposable ne fige la forme des réponses, et le serveur ignore la version de l’app qui l’appelle. Le jour où un bug de la V1 devra être coupé à distance, il faudra l’avoir prévu.
Sur un projet comme celui-ci, l’ops est même le cœur du sujet, bien avant le dev. Ce n’est pas un projet localhost, loin de là : c’est un vrai projet, hébergé et livré.
Pour aller plus loin, la partie iOS reste un choix personnel. Étant dans l’écosystème Apple, il est plus simple et plus pertinent de faire ce genre de chose en Swift pour le moment. API Platform, en tant que standard, sera comme dit plus haut la suite logique quand le projet grossira et qu’il faudra travailler à plusieurs. Mais c’est l’ops qui dessine le système, donc l’approche reste incrémentale.