16 sept. 2026

WhatsInMyBar

Une plateforme de recettes de cocktails avec une API Symfony et API Platform, une interface Nuxt et des parcours de publication, de partage et de modération.

Visuel de présentation du portfolio de Hugo Charrade

Le projet

WhatsInMyBar est une application de découverte et de partage de recettes de cocktails, avec ou sans alcool. Elle permet de parcourir un catalogue, de publier ses propres recettes, de les enregistrer en favoris et d’échanger dans les commentaires.

J’ai développé ce projet personnel autour d’un contenu simple en apparence: une recette. Derrière sa présentation, il faut pourtant coordonner des ingrédients, des quantités, des étapes, des images et plusieurs états de publication, tout en conservant des droits cohérents pour les visiteurs, les auteurs et les administrateurs.

L’application est disponible en anglais. Elle réunit une interface de consultation, un espace personnel et une administration pour gérer les contenus et les signalements.

De Popotion à WhatsInMyBar

WhatsInMyBar est une réédition personnelle de Popotion[28], un projet réalisé en groupe pendant ma troisième année de BUT Informatique. J’avais été responsable d’une grande partie de la création de son API. Cette première version proposait déjà un catalogue de recettes, des ingrédients, des catégories, des commentaires et des favoris.

J’ai repris ce point de départ pour retravailler l’application dans son ensemble. Popotion utilisait déjà Symfony[1] 6.4 et API Platform[3] 3.2. J’ai conservé cette approche, en actualisant le socle technique et en approfondissant les règles métier, les tests et l’exploitation. Le frontend Vue[8] construit avec Vite a laissé place à Nuxt[7], notamment pour rendre les pages de recettes côté serveur et gérer leurs métadonnées de partage.

Le modèle des recettes a également évolué. Dans Popotion, les étapes de préparation étaient stockées dans un tableau au sein de la recette. WhatsInMyBar les représente par des entités ordonnées, avec une validation commune des conditions de publication. L’éditeur enregistre les métadonnées, les ingrédients et les étapes dans une transaction, tandis que la gestion des images tient compte à la fois des droits d’accès et de la persistance des fichiers.

Cette reprise m’a permis de prolonger le travail réalisé sur l’API tout en prenant en charge le frontend et le déploiement. L’objectif est de faire évoluer le projet d’école vers une application personnelle exploitable sur un VPS, avec une session cohérente entre serveur et navigateur, des contrôles de qualité automatisés et une chaîne de livraison reproductible.

Ce que permet l’application

Chaque recette rassemble une description, des ingrédients et leurs quantités, des étapes ordonnées, des catégories, une difficulté, un temps de préparation et un nombre de portions. Le catalogue propose une recherche textuelle, des filtres par catégorie ou ingrédient et des tris par date ou popularité.

Un utilisateur peut créer un brouillon, modifier sa recette, ajouter une image, puis la publier ou l’archiver. Son profil présente ses publications. Ses recettes personnelles et ses favoris disposent de vues dédiées. Les commentaires acceptent des réponses, avec une profondeur limitée pour garder les discussions lisibles.

L’administration permet de gérer les comptes, les recettes, les catégories et les ingrédients, ainsi que de traiter les signalements. Le statut éditorial d’une recette et son état de modération sont distincts: une publication peut être masquée sans perdre son contenu.

Pourquoi Symfony et API Platform ?

Le backend utilise Symfony 8.1[1] avec PHP 8.4[2], API Platform 4.3[3] et Doctrine ORM[4]. Cette combinaison fournit une base pour exposer des ressources structurées, valider les données et gérer les autorisations, tout en laissant la place aux traitements propres au projet.

Symfony[1] regroupe les services applicatifs, la validation et la sécurité. Les règles de publication ou de visibilité peuvent ainsi être réutilisées par plusieurs routes, au lieu d’être reproduites dans chaque contrôleur.

API Platform pour les ressources et leurs collections

API Platform[3] fournit le socle des opérations sur les recettes et les taxonomies: lecture, écriture, sérialisation et pagination. Les groupes de sérialisation séparent les propriétés exposées de celles que le client peut modifier. Des extensions de requêtes ajoutent les filtres de recherche et les restrictions de visibilité.

Les parcours plus spécifiques disposent de traitements dédiés. Enregistrer une recette complète, publier un brouillon ou traiter un signalement ne se résume pas toujours à modifier une propriété. Le projet associe donc les ressources du framework à des contrôleurs et services métier explicites.

Ce choix réduit le travail répétitif, mais demande de comprendre les points d’extension du framework. Une règle doit rester vraie aussi bien sur une opération standard que sur une route personnalisée.

Doctrine et PostgreSQL pour la cohérence des données

Doctrine ORM[4] porte les relations entre recettes, auteurs, catégories, étapes et ingrédients. Une ligne d’ingrédient appartient à une recette et ajoute au référentiel commun sa quantité, son unité, sa position et une éventuelle note.

PostgreSQL 16[5] assure la persistance. Les transactions et les contraintes d’unicité complètent les validations applicatives, notamment lorsque plusieurs requêtes arrivent en même temps. Certaines opérations utilisent directement Doctrine DBAL[6] pour exprimer précisément les verrous ou les mises à jour atomiques nécessaires.

Pourquoi Nuxt et Vue ?

Le frontend utilise Nuxt 4[7], Vue 3[8] et TypeScript[9]. Le projet comporte des pages de contenu consultables directement depuis un lien: recettes, catégories et profils. Le rendu serveur permet d’envoyer leur contenu initial et leurs métadonnées avant l’exécution du JavaScript dans le navigateur.

Nuxt[7] fournit le routage, le chargement des données et les outils de gestion des métadonnées. Les interactions prennent ensuite le relais côté client: filtres, favoris, commentaires et édition. Les pages disposent d’URL canoniques et de métadonnées de partage. Le sitemap suit les contenus accessibles sans connexion.

Une SPA seule aurait demandé un travail supplémentaire pour servir le contenu initial aux visiteurs et aux robots. Une interface entièrement rendue par le backend aurait également été possible. Ici, la séparation permet de travailler sur un véritable contrat d’API et de faire évoluer l’interface indépendamment des contrôleurs serveur.

Le coût de ce choix est concret: deux applications à maintenir, des types et des erreurs à faire correspondre, et une session à rendre cohérente entre serveur et navigateur. L’API reste responsable des règles métier. L’interface ne doit pas devenir une seconde implémentation des autorisations.

Une interface sobre et des composants réutilisables

L’interface repose sur Tailwind CSS 4[10] et des composants shadcn-vue[11], intégrés avec shadcn-nuxt[12] et construits sur les primitives de Reka UI[13]. Cette base permet d’adapter les composants dans le projet tout en s’appuyant sur des comportements existants pour les menus, dialogues et contrôles de formulaire.

La palette zinc, les thèmes clair et sombre et les composants communs donnent une continuité au catalogue, à l’éditeur et à l’administration. Les formulaires d’édition occupent des pages dédiées pour laisser de la place aux ingrédients, aux étapes et aux erreurs de validation.

Architecture de l’application

Le monorepo contient deux applications, apps/api et apps/web. Caddy[14] est le point d’entrée HTTP et HTTPS: il dirige les pages vers Nuxt[7] et les requêtes /api vers Symfony[1], servi par FrankenPHP[15].

Pendant le rendu serveur, Nuxt[7] contacte directement l’API sur un réseau privé. Après hydratation, le navigateur utilise le même domaine et le préfixe /api. PostgreSQL[5] et les volumes persistants restent du côté backend. Les fichiers uploadés ne sont pas incorporés aux images applicatives.

Architecture de WhatsInMyBarLe navigateur passe par Caddy. Les pages vont vers Nuxt et les requêtes API vers Symfony. Nuxt appelle également l'API pendant le rendu serveur. Seule l'API accède aux données et aux fichiers.Pages/apiAppels SSRNavigateurPages et interactionsCaddyEntrée HTTP / HTTPSNuxtRendu serveur et frontendSymfony · API PlatformServis par FrankenPHPVolumes locauxImages et compteursPostgreSQLDonnées et sessions de refresh
Les flèches indiquent le sens des appels. Les réponses suivent le chemin inverse. Les échanges SSR et les accès à la base utilisent des réseaux privés.

Une session cohérente entre serveur et navigateur

LexikJWT et Gesdinet: deux rôles complémentaires

LexikJWTAuthenticationBundle[16] gère les JWT d’accès, d’une durée de quinze minutes. Gesdinet JWT Refresh Token Bundle[17] fournit la base de gestion des jetons de renouvellement et leur émission à la connexion. La rotation et la révocation passent ensuite par un service applicatif dédié, pour maîtriser leur comportement concurrent.

Le JWT d’accès est conservé en mémoire côté navigateur. Le jeton de renouvellement, valable trente jours, utilise un cookie HttpOnly, SameSite=Strict et Secure en production. Il n’est pas exposé au JavaScript et aucun jeton d’authentification n’est conservé dans le localStorage.

Le cookie porte sur / afin que Nuxt[7] puisse restaurer la session lors d’une requête de page. Le serveur relaie le cookie de renouvellement uniquement aux routes d’authentification concernées, puis utilise le JWT pour les lectures authentifiées. Les nouveaux cookies sont transmis au navigateur. Le jeton de renouvellement n’est jamais sérialisé dans le contenu de la page.

Gérer les renouvellements concurrents et les pannes

Plusieurs appels ou onglets peuvent tenter un renouvellement en même temps. Le client mutualise les demandes au sein d’une même instance. Côté API, une transaction avec verrou de ligne protège la rotation dans PostgreSQL[5].

Une fenêtre de grâce de dix secondes permet à des demandes concurrentes utilisant le jeton précédent de recevoir le même successeur. Répéter la demande ne prolonge ni cette fenêtre ni l’expiration du successeur. Ce mécanisme couvre les courses courantes, sans rendre l’ancien jeton réutilisable indéfiniment.

Une session invalide et une panne temporaire n’ont pas le même sens. Un refus d’authentification efface l’état connecté. Une erreur réseau ou serveur place l’interface dans un état dégradé avec possibilité de reprise. Les réponses tardives d’une ancienne session sont écartées pour éviter de réinjecter les données d’un autre compte.

Les réponses API et les pages rendues utilisent actuellement Cache-Control: private, no-store. Ce choix privilégie la séparation des données personnelles et simplifie les règles de cache, au prix d’une moindre mise en cache des lectures publiques. Une optimisation ultérieure devra distinguer les réponses anonymes des réponses personnalisées.

Faire respecter la visibilité dans l’API

Les voters Symfony[18] vérifient les droits sur une recette: lecture, gestion par son auteur, état de publication, modération et rôle administrateur. Les collections sont également filtrées côté serveur: masquer une fiche dans l’interface ne suffirait pas si elle restait accessible par son URL ou dans une recherche.

Le caractère alcoolisé d’une recette est calculé à partir de ses ingrédients, avec une possibilité de correction administrative. Les visiteurs anonymes et les comptes déclarés mineurs accèdent uniquement aux recettes sans alcool. Les comptes adultes peuvent accéder aux recettes alcoolisées et les administrateurs disposent d’une dérogation. Il s’agit d’une règle fondée sur la date de naissance déclarée, pas d’une vérification d’identité.

Ces décisions s’appliquent aussi à la diffusion des images. Une recette devenue inaccessible ne doit pas continuer à exposer son illustration par une URL statique indépendante de ses droits.

Enregistrer une recette comme un ensemble

L’éditeur transmet les métadonnées, les catégories, les ingrédients et les étapes dans une requête d’enregistrement groupée. Le backend valide la structure, résout les références et construit une proposition indépendante avant de modifier les collections persistées.

Cette proposition permet de vérifier les droits sur le résultat demandé. Par exemple, un utilisateur ne doit pas pouvoir transformer une recette accessible en recette alcoolisée si son profil ne l’autorise pas. Si la recette est déjà publiée, son édition doit aussi préserver les conditions de publication.

L’enregistrement se fait ensuite dans une transaction Doctrine[4]. Une erreur pendant l’opération ne doit pas laisser les nouvelles étapes associées aux anciens ingrédients. L’upload de l’image utilise une opération séparée, avec sa propre gestion du fichier et de la transaction.

Enregistrement d'une recetteL'éditeur transmet la recette complète. L'API valide la proposition et les droits. En cas de refus, les données restent inchangées. Sinon une transaction applique toutes les modifications et les valide ensemble, ou les annule en cas d'échec.Proposition acceptéeCommit réussiÉditeur de recetteMétadonnées, ingrédients et étapesValidation de la propositionStructure, références et autorisationsDemande refuséeAucune modification en baseTransaction en baseRecette et relations enregistréesEnregistrement validéBrouillon ou publication préservéeTransaction annuléeModifications annulées
Une nouvelle recette est créée en brouillon. La publication est une action distincte. L'édition d'une recette déjà publiée doit préserver ses conditions de publication. L'image suit une opération séparée.

La publication dispose d’une validation centralisée: métadonnées valides, ingrédients présents et étapes renseignées. Cette validation s’applique aux différentes routes qui peuvent publier une recette, y compris les opérations administratives concernées.

Les favoris répondent à un autre problème de cohérence: répéter un ajout ne doit pas augmenter plusieurs fois le compteur. Une contrainte unique sur le couple utilisateur-recette et un INSERT ... ON CONFLICT DO NOTHING rendent l’ajout idempotent. Le compteur n’est modifié que lorsqu’une ligne est réellement créée ou supprimée, dans la même transaction.

Valider les images et contrôler leur diffusion

Les uploads acceptent les formats JPEG, PNG et WebP, avec des limites de poids, de dimensions et de nombre total de pixels. La vérification ne se contente pas de l’extension ou du type MIME annoncé: GD[19] doit pouvoir décoder l’image.

Les pixels sont ensuite copiés dans une nouvelle image, redimensionnés et réencodés. Les fichiers d’origine et leurs métadonnées ne sont pas publiés tels quels. Les avatars sont limités à 512 pixels sur leur plus grand côté, les images de recettes à 1 600 pixels, sans agrandissement des petites images.

Lors d’un remplacement, l’ancien fichier reste disponible jusqu’à la validation de la transaction en base. Le nettoyage vérifie qu’il n’est plus référencé avant de le supprimer. Les fichiers orphelins disposent d’une commande de maintenance avec un mode de prévisualisation.

Les images de recettes sont servies par une route API qui réévalue les droits de lecture. Leur chemin de stockage ne donne pas accès directement au fichier. Pour les lectures authentifiées, le frontend récupère l’image avec le JWT et crée une URL temporaire en mémoire. Les identifiants ne figurent pas dans l’URL. Les avatars restent publics.

Limiter les abus et maîtriser les collections

Symfony RateLimiter[20] limite les tentatives de connexion et d’inscription ainsi que les écritures authentifiées. Les budgets reposent sur l’adresse IP, le couple IP-adresse e-mail ou l’utilisateur selon l’opération. Les refus renvoient un statut 429 accompagné de Retry-After pour indiquer le délai d’attente.

Les compteurs sont conservés dans un volume local persistant et leurs mises à jour sont protégées par un verrou. Cette solution convient à l’hébergement sur un seul VPS. Plusieurs instances réparties sur plusieurs machines nécessiteraient de revoir ce stockage. La confiance accordée aux en-têtes d’adresse IP est limitée aux proxies configurés.

Les recettes sont paginées côté API. Le frontend exploite les métadonnées de collection pour connaître le total et les pages disponibles, plutôt que de confondre la longueur de la page reçue avec la taille du catalogue. Les filtres restent dans l’URL et leur modification réinitialise la pagination. Les commentaires disposent eux aussi d’une pagination et les requêtes d’édition limitent le nombre d’éléments acceptés.

Du développement au déploiement

Docker Compose[21] fournit l’environnement de développement et l’assemblage des services en production. Sur le VPS, l’API et le serveur web utilisent deux images distinctes, avec PostgreSQL[5] et des volumes pour les uploads et les compteurs de limitation. Les conteneurs applicatifs ne publient pas de ports directement sur l’hôte. Le reverse proxy partagé assure l’entrée publique.

GitHub Actions[22] exécute les validations backend, frontend et conteneurs. Un job Release gate exige la réussite des trois avant la publication des images sur GHCR[23], identifiées par le SHA du commit.

Le workflow de déploiement écarte une révision qui n’est plus la tête de main au moment du contrôle, résout les images par digest, applique les migrations et attend que les services soient sains. Il vérifie également les routes publiques de contrôle. Un verrou sur le VPS sérialise les opérations et les références de la version précédente sont conservées pour faciliter une intervention en cas d’échec.

La persistance dépasse la base de données: une restauration cohérente doit associer PostgreSQL[5] aux fichiers uploadés. Le stockage local simplifie cette première mise en production, mais implique de gérer les sauvegardes, l’espace disque et le nettoyage des fichiers dans l’exploitation du serveur.

Vérifier les parcours, pas seulement les composants

Le backend associe PHPUnit[24] et PHPStan[25] aux contrôles de migrations et de schéma. Le frontend utilise Vitest[26], le contrôle TypeScript[9] et des scénarios Playwright[27].

Les tests navigateur avec API simulée sont complétés par une suite sur la stack réelle. Celle-ci exerce notamment la connexion, le renouvellement concurrent, la restauration de session pendant le rendu serveur, les règles de visibilité, la publication, les images et la modération.

Les validations des conteneurs vérifient également le routage et la persistance après recréation des services. Elles apportent une couverture différente des tests unitaires: une fonctionnalité correcte isolément doit encore fonctionner avec les images et les réseaux utilisés pour le déploiement.

Bilan et pistes d’amélioration

WhatsInMyBar m’a permis de travailler sur une application dont la cohérence se joue entre plusieurs couches: le contrat HTTP, le rendu serveur, les autorisations, la base de données et les fichiers. Les difficultés les plus intéressantes apparaissent lorsqu’une opération est répétée, interrompue ou exécutée en parallèle.

Les prochaines évolutions concernent notamment la récupération de mot de passe, la vérification de l’adresse e-mail et la suppression de compte en libre-service. Ces parcours restent à développer et nécessitent une base d’envoi d’e-mails adaptée.

L’enrichissement du catalogue public est également essentiel: la qualité des recettes et des illustrations rendra la découverte plus intéressante dès la première visite. Côté technique, une mise en cache ciblée des contenus anonymes et une meilleure observabilité permettraient de faire évoluer l’application à partir de son usage réel.

Références techniques

Les numéros dans le texte renvoient aux documentations officielles des bibliothèques et outils utilisés.

  1. Symfony: Services, validation et sécurité.
  2. PHP: Manuel du langage.
  3. API Platform: Ressources, opérations, sérialisation et pagination.
  4. Doctrine ORM: Relations, persistance et transactions.
  5. PostgreSQL 16: Transactions, contraintes et verrous.
  6. Doctrine DBAL: Accès SQL et gestion des connexions.
  7. Nuxt: Rendu serveur et hydratation.
  8. Vue: Composants et réactivité.
  9. TypeScript: Typage du frontend.
  10. Tailwind CSS: Styles utilitaires.
  11. shadcn-vue: Composants adaptables dans le projet.
  12. shadcn-nuxt: Intégration des composants dans Nuxt.
  13. Reka UI: Primitives de composants d’interface.
  14. Caddy: Reverse proxy et entrée HTTPS.
  15. FrankenPHP: Serveur applicatif PHP.
  16. LexikJWTAuthenticationBundle: Authentification par JWT.
  17. Gesdinet JWT Refresh Token Bundle: Documentation officielle des jetons de renouvellement.
  18. Voters Symfony: Décisions d’autorisation sur les ressources.
  19. GD: Décodage, redimensionnement et réencodage des images.
  20. Symfony RateLimiter: Budgets et fenêtres de limitation.
  21. Docker Compose: Services, réseaux et volumes.
  22. GitHub Actions: Automatisation des validations et du déploiement.
  23. GitHub Container Registry: Publication des images de conteneurs.
  24. PHPUnit: Tests automatisés du backend.
  25. PHPStan: Analyse statique PHP.
  26. Vitest: Tests du frontend.
  27. Playwright: Tests dans un navigateur.
  28. Popotion: Projet collectif de troisième année de BUT Informatique à l’origine de WhatsInMyBar. Dépôts de l’API et du frontend.