The project
WhatsInMyBar is an application for discovering and sharing cocktail recipes, with or without alcohol. Users can browse a catalogue, publish their own recipes, save favourites, and join discussions in the comments.
I built this personal project around a type of content that seems simple at first: a recipe. Presenting one properly, however, means coordinating ingredients, quantities, steps, images, and several publication states while maintaining consistent permissions for visitors, authors, and administrators.
The application is available in English. It combines a browsing interface, a personal area, and an administration panel for managing content and reports.
From Popotion to WhatsInMyBar
WhatsInMyBar is my personal reworking of Popotion[28], a group project completed during the third year of my university bachelor’s degree in computer science. I was responsible for a large part of its API. That first version already provided a recipe catalogue, ingredients, categories, comments, and favourites.
I used that starting point to rework the application as a whole. Popotion already used Symfony[1] 6.4 and API Platform[3] 3.2. I retained this approach while updating the technical foundation and developing the domain rules, tests, and operations further. The Vue[8] frontend built with Vite was replaced by Nuxt[7], notably to server-render recipe pages and manage their sharing metadata.
The recipe model also evolved. In Popotion, preparation steps were stored as an array inside the recipe. WhatsInMyBar represents them as ordered entities with shared validation of the publication requirements. The editor saves metadata, ingredients, and steps within a transaction, while image management accounts for both access permissions and file persistence.
This reworking allowed me to extend my previous API work while taking responsibility for the frontend and deployment as well. The goal is to evolve a university project into a personal application that can run on a VPS, with a session that remains consistent between server and browser, automated quality checks, and a reproducible delivery pipeline.
What the application does
Each recipe contains a description, ingredients and their quantities, ordered steps, categories, a difficulty level, preparation time, and a serving count. The catalogue offers text search, category and ingredient filters, and sorting by date or popularity.
Users can create a draft, edit their recipe, add an image, and then publish or archive it. Their profile presents their published recipes, while dedicated views list their own recipes and favourites. Comments support replies with limited nesting to keep discussions readable.
The administration area can manage accounts, recipes, categories, and ingredients, as well as process reports. A recipe’s editorial status and moderation state are separate: a publication can be hidden without losing its content.
Why Symfony and API Platform?
The backend uses Symfony 8.1[1] with PHP 8.4[2], API Platform 4.3[3], and Doctrine ORM[4]. This combination provides a foundation for exposing structured resources, validating data, and managing authorization while leaving room for project-specific processing.
Symfony[1] brings together application services, validation, and security. Publication and visibility rules can therefore be reused by several routes instead of being duplicated in each controller.
API Platform for resources and collections
API Platform[3] provides the foundation for operations on recipes and taxonomies: reading, writing, serialization, and pagination. Serialization groups separate the properties exposed to clients from those they may modify. Query extensions add search filters and visibility restrictions.
More specific workflows use dedicated handlers. Saving a complete recipe, publishing a draft, or processing a report cannot always be reduced to changing a property. The project therefore combines framework resources with explicit domain controllers and services.
This choice reduces repetitive work but requires an understanding of the framework’s extension points. A rule must remain true for both standard operations and custom routes.
Doctrine and PostgreSQL for data consistency
Doctrine ORM[4] manages the relationships between recipes, authors, categories, steps, and ingredients. An ingredient line belongs to a recipe and supplements the shared ingredient catalogue with its quantity, unit, position, and an optional note.
PostgreSQL 16[5] provides persistence. Transactions and uniqueness constraints complement application validation, particularly when several requests arrive concurrently. Some operations use Doctrine DBAL[6] directly to express the required locks or atomic updates precisely.
Why Nuxt and Vue?
The frontend uses Nuxt 4[7], Vue 3[8], and TypeScript[9]. The project includes content pages that visitors can open directly from a link: recipes, categories, and profiles. Server rendering sends their initial content and metadata before JavaScript runs in the browser.
Nuxt[7] provides routing, data loading, and metadata tools. Client-side interactions then take over for filters, favourites, comments, and editing. Pages have canonical URLs and sharing metadata, while the sitemap follows content accessible without signing in.
A standalone SPA would have required additional work to serve initial content to visitors and crawlers. An interface rendered entirely by the backend would also have been possible. Here, the separation makes it possible to work with a genuine API contract and evolve the interface independently from the server controllers.
The cost of this choice is concrete: two applications to maintain, types and errors to align, and a session that must remain consistent between server and browser. The API stays responsible for the domain rules. The interface must not become a second implementation of authorization.
A restrained interface and reusable components
The interface uses Tailwind CSS 4[10] and shadcn-vue[11] components, integrated through shadcn-nuxt[12] and built on Reka UI[13] primitives. This foundation makes it possible to adapt components within the project while relying on established behaviour for menus, dialogs, and form controls.
The zinc palette, light and dark themes, and shared components provide continuity across the catalogue, editor, and administration area. Editing forms use dedicated pages to leave enough room for ingredients, steps, and validation errors.
Application architecture
The monorepo contains two applications, apps/api and apps/web. Caddy[14] is the HTTP and HTTPS entry point: it routes pages to Nuxt[7] and /api requests to Symfony[1], served by FrankenPHP[15].
During server rendering, Nuxt[7] contacts the API directly over a private network. After hydration, the browser uses the same domain and the /api prefix. PostgreSQL[5] and persistent volumes remain on the backend side. Uploaded files are not embedded in the application images.
A consistent session between server and browser
LexikJWT and Gesdinet: complementary roles
LexikJWTAuthenticationBundle[16] manages access JWTs with a fifteen-minute lifetime. Gesdinet JWT Refresh Token Bundle[17] provides the foundation for managing refresh tokens and issuing them at login. Rotation and revocation then pass through a dedicated application service to control their concurrent behaviour.
The access JWT is held in browser memory. The refresh token, valid for thirty days, uses an HttpOnly, SameSite=Strict cookie that is Secure in production. It is not exposed to JavaScript, and no authentication token is stored in localStorage.
The cookie applies to / so Nuxt[7] can restore the session during a page request. The server forwards the refresh cookie only to the relevant authentication routes, then uses the JWT for authenticated reads. New cookies are passed back to the browser. The refresh token is never serialized into the page content.
Handling concurrent refreshes and failures
Several requests or tabs may attempt a refresh at the same time. The client coalesces requests within a single instance. On the API side, a transaction with a row lock protects rotation in PostgreSQL[5].
A ten-second grace window allows concurrent requests using the previous token to receive the same successor. Repeating the request extends neither that window nor the successor’s expiration. This mechanism covers common race conditions without making the old token reusable indefinitely.
An invalid session and a temporary outage do not mean the same thing. An authentication refusal clears the signed-in state. A network or server error places the interface in a degraded but recoverable state. Late responses from an earlier session are discarded to avoid injecting data from another account.
API responses and rendered pages currently use Cache-Control: private, no-store. This choice prioritizes the separation of personal data and simplifies cache rules at the cost of reduced caching for public reads. A future optimization will need to distinguish anonymous responses from personalized ones.
Enforcing visibility in the API
Symfony voters[18] check permissions on a recipe: reading, management by its author, publication status, moderation, and administrator role. Collections are filtered on the server as well; hiding a record in the interface would not be enough if it remained available through its URL or in search results.
Whether a recipe is alcoholic is calculated from its ingredients, with an administrative override. Anonymous visitors and accounts declared as minors can access only alcohol-free recipes. Adult accounts can access alcoholic recipes, while administrators are exempt. This is a rule based on the declared date of birth, not an identity check.
These decisions also apply when serving images. A recipe that becomes inaccessible must not continue exposing its illustration through a static URL independent of its permissions.
Saving a recipe as a whole
The editor sends metadata, categories, ingredients, and steps in one grouped save request. The backend validates the structure, resolves references, and builds an independent proposed state before modifying persisted collections.
This proposal makes it possible to check permissions against the requested result. For example, a user must not be able to turn an accessible recipe into an alcoholic one if their profile does not allow it. Editing an already published recipe must also preserve its publication requirements.
The save then runs inside a Doctrine[4] transaction. An error during the operation must not leave new steps associated with old ingredients. Image uploads use a separate operation with their own file and transaction handling.
Publication uses centralized validation: valid metadata, at least one ingredient, and completed steps. This validation applies to every route that can publish a recipe, including the relevant administrative operations.
Favourites present a different consistency problem: repeating an add request must not increment the counter more than once. A unique constraint on the user-recipe pair and an INSERT ... ON CONFLICT DO NOTHING make the addition idempotent. The counter changes only when a row is actually created or deleted, within the same transaction.
Validating images and controlling their delivery
Uploads accept JPEG, PNG, and WebP with limits on file size, dimensions, and total pixel count. Validation does not rely only on the extension or declared MIME type: GD[19] must be able to decode the image.
The pixels are then copied into a new image, resized, and re-encoded. Original files and their metadata are not published unchanged. Avatars are limited to 512 pixels on their longest side and recipe images to 1,600 pixels, without upscaling smaller images.
During replacement, the previous file remains available until the database transaction commits. Cleanup checks that it is no longer referenced before deleting it. A maintenance command with a preview mode handles orphaned files.
Recipe images are served through an API route that reevaluates read permissions. Their storage path does not provide direct access to the file. For authenticated reads, the frontend fetches the image with the JWT and creates a temporary in-memory URL. Credentials never appear in the URL. Avatars remain public.
Limiting abuse and managing collections
Symfony RateLimiter[20] limits login and registration attempts as well as authenticated writes. Budgets are based on the IP address, the IP-and-email pair, or the user depending on the operation. Rejections return a 429 status with Retry-After to indicate the waiting period.
Counters are stored in a persistent local volume, and their updates are protected by a lock. This solution is appropriate for hosting on a single VPS. Multiple instances distributed across several machines would require a different storage strategy. Only configured proxies are trusted to supply client IP headers.
Recipes are paginated by the API. The frontend uses collection metadata to determine the total and available pages rather than confusing the length of the received page with the size of the catalogue. Filters remain in the URL, and changing them resets pagination. Comments are paginated as well, and edit requests limit the number of accepted items.
From development to deployment
Docker Compose[21] provides the development environment and assembles the services in production. On the VPS, the API and web server use two separate images alongside PostgreSQL[5] and volumes for uploads and rate-limit counters. Application containers do not expose ports directly on the host. The shared reverse proxy provides the public entry point.
GitHub Actions[22] runs backend, frontend, and container validations. A Release gate job requires all three to succeed before publishing images to GHCR[23], identified by the commit SHA.
The deployment workflow rejects a revision that is no longer the head of main when checked, resolves images by digest, applies migrations, and waits for services to become healthy. It also verifies public health routes. A lock on the VPS serializes operations, and references to the previous version are retained to facilitate intervention after a failure.
Persistence extends beyond the database: a consistent restore must pair PostgreSQL[5] with uploaded files. Local storage simplifies this initial production setup but makes backups, disk capacity, and file cleanup part of server operations.
Testing workflows, not only components
The backend combines PHPUnit[24] and PHPStan[25] with migration and schema checks. The frontend uses Vitest[26], TypeScript checks[9], and Playwright[27] scenarios.
Browser tests with a mocked API are complemented by a suite running against the real stack. It covers login, concurrent refreshes, session restoration during server rendering, visibility rules, publishing, images, and moderation, among other workflows.
Container validations also verify routing and persistence after services are recreated. They provide different coverage from unit tests: a feature that works correctly in isolation must still function with the images and networks used for deployment.
Takeaways and next steps
WhatsInMyBar allowed me to work on an application whose consistency spans several layers: the HTTP contract, server rendering, authorization, the database, and files. The most interesting challenges appear when an operation is repeated, interrupted, or executed concurrently.
The next developments include password recovery, email verification, and self-service account deletion. These workflows remain to be built and require a suitable email delivery foundation.
Expanding the public catalogue is equally important: high-quality recipes and illustrations will make discovery more useful from the first visit. On the technical side, targeted caching of anonymous content and improved observability would allow the application to evolve based on real usage.
Technical references
The numbers in the text refer to the official documentation for the libraries and tools used.
- Symfony: Services, validation, and security.
- PHP: Language manual.
- API Platform: Resources, operations, serialization, and pagination.
- Doctrine ORM: Relationships, persistence, and transactions.
- PostgreSQL 16: Transactions, constraints, and locks.
- Doctrine DBAL: SQL access and connection management.
- Nuxt: Server rendering and hydration.
- Vue: Components and reactivity.
- TypeScript: Frontend type safety.
- Tailwind CSS: Utility-first styles.
- shadcn-vue: Adaptable components within the project.
- shadcn-nuxt: Component integration for Nuxt.
- Reka UI: Interface component primitives.
- Caddy: Reverse proxy and HTTPS entry point.
- FrankenPHP: PHP application server.
- LexikJWTAuthenticationBundle: JWT authentication.
- Gesdinet JWT Refresh Token Bundle: Official refresh-token documentation.
- Symfony voters: Authorization decisions for resources.
- GD: Image decoding, resizing, and re-encoding.
- Symfony RateLimiter: Rate limit budgets and windows.
- Docker Compose: Services, networks, and volumes.
- GitHub Actions: Validation and deployment automation.
- GitHub Container Registry: Publishing container images.
- PHPUnit: Automated backend tests.
- PHPStan: Static PHP analysis.
- Vitest: Frontend tests.
- Playwright: Browser tests.
- Popotion: Third-year university group project that WhatsInMyBar originated from. Repositories for the API and frontend.