API Conventions
This document outlines the conventions used for API development in Meo Mai Moi, ensuring consistency across the backend, documentation, and frontend.
Response Envelope
All API responses follow a standard JSON envelope format. This consistency allows the frontend to have a single entry point for handling data, messages, and errors.
Success Response
{
"success": true,
"data": {
"id": 1,
"name": "Fluffy"
},
"message": "Pet retrieved successfully"
}success(boolean): Alwaystrue.data(object|array): The primary payload of the response.message(string): A short, human-readable description of the result.
Exception:
204 No Contentresponses intentionally return an empty body rather than the JSON envelope. Use these for idempotent delete/mark-read style endpoints where there is no payload to return.
Error Response
{
"success": false,
"message": "The given data was invalid.",
"errors": {
"name": ["The name field is required."]
}
}success(boolean): Alwaysfalse.message(string): A human-readable description of the error.errors(object): Optional. Field-specific validation errors.
Backend Implementation
ApiResponseTrait
Controllers should use the App\Traits\ApiResponseTrait to generate responses.
sendSuccess($data, $statusCode): Standard success response.sendError($message, $statusCode): Standard error response.sendNoContent(): Empty204 No Contentresponse.sendSuccessWithMeta($data, $message, $statusCode): Success response where the message is also nested in data (useful for frontend interceptors).
OpenAPI (Swagger) Documentation
All endpoints must be annotated with OpenAPI attributes. Use the centralized schemas in app/Schemas/ResponseSchemas.php to define the response structure:
#[OA\Response(
response: 200,
description: 'Success',
content: new OA\JsonContent(ref: '#/components/schemas/PetResponse')
)]Frontend Consumption
Typesafe API Client (Orval)
We use Orval to generate a fully typesafe API client and React Query hooks from our backend's OpenAPI api-docs.json. This eliminates manual wiring of query keys, response types, and invalidations.
Workflow:
- Update backend OpenAPI annotations.
- Run
vp run api:generatein thefrontenddirectory. - Import the generated hooks from
@/api/generated/.
Example:
import { useGetPets } from '@/api/generated/pets/pets'
const { data: pets } = useGetPets() // pets is automatically typed as Pet[]The generated client is configured via frontend/src/api/orval-mutator.ts. It uses our centralized Axios instance and automatically accounts for the data envelope unwrapping at both the runtime (via interceptors) and type level (via Orval transformers).
Automatic Unwrapping
We use two mechanisms to simplify data access:
- Axios Interceptor: Defined in
frontend/src/api/axios.ts, it automatically unwraps thedatakey from the backend's JSON envelope. - Orval Transformer: Defined in
frontend/orval.config.ts, it unwraps the{ data: T }type in the generated hooks, so callers receive the payload directly.
Both work together to ensure that const { data } = useGetPets() gives you the actual list of pets, not the { data: pets } structure.
Internationalization (i18n)
For models with translatable fields (like PetType, Category, and City), the backend uses a concern that automatically serializes these fields as strings based on the current app locale (determined by the Accept-Language header).
- Backend: Data is stored as JSONB in the database.
- API Response: Returns a simple string for the current locale.
- Frontend: Receives a string, no special handling required for display.
Example Category response for Accept-Language: vi:
{
"id": 1,
"name": "Mèo Siamese",
"slug": "siamese"
}Media Images
Spatie Media Library-backed images should use the shared MediaImage payload where practical. Gallery-style fields such as pet photos, helper profile photos, and medical record photos return arrays of image objects. Single-image fields can keep their legacy URL field for compatibility while also exposing a structured object, such as avatar_url plus avatar or photo_url plus photo.
{
"id": 123,
"url": "https://example.test/storage/...",
"thumb_url": "https://example.test/storage/...",
"medium_url": "https://example.test/storage/...",
"webp_url": "https://example.test/storage/...",
"srcset": "https://example.test/storage/... 640w, https://example.test/storage/... 1280w",
"sources": [
{
"type": "image/webp",
"srcset": "https://example.test/storage/... 640w, https://example.test/storage/... 1280w"
}
],
"width": 1280,
"height": 960,
"is_primary": true,
"processing": false
}urlis the default display URL. It may point to a generated conversion rather than the original upload.thumb_url,medium_url, andwebp_urlare nullable conversion URLs.srcsetcontains fallback-format responsive candidates andsourcescontains alternative-format candidates suitable for<picture>.widthandheightare nullable for legacy or unsupported media; new raster uploads record reliable intrinsic dimensions when the file enters Media Library.is_primarymarks the ordered primary image for galleries; single-image payloads set it totrue.processingistruewhen one or more expected generated conversions are not ready yet.- Rendering contexts choose their own HTML
sizesvalue because layout knowledge belongs to the frontend, not the API. - After changing media response shapes, update OpenAPI annotations and run
vp run api:generateplusvp run api:check. - After deploying responsive media support to an installation with existing uploads, run
php artisan media:optimize-images --forceonce. The command records missing dimensions and queues current responsive/WebP derivatives without changing originals.
CI/Deployment Integration
The API client is regenerated automatically during deployment:
- Docker Build: The Dockerfile runs the frontend build path, which includes
vp run api:generatebefore building the frontend assets. - Deploy Script:
utils/deploy.shincludes a pre-build check that runsvp run api:generateto catch OpenAPI spec drift early. - Local Check: Run
vp run api:checkto verify generated code matches the committed OpenAPI spec.
Exceptions (Manual API Calls)
Some endpoints remain outside the generated client by design:
- Fortify Auth Routes (
/login,/register,/logout,/forgot-password,/reset-password): These Laravel Fortify routes are not documented in OpenAPI and use a separateauthApiAxios instance. - CSRF Token (
/sanctum/csrf-cookie): Handled directly via thecsrf()helper function. - Email Unsubscribe (
GET /unsubscribe,POST /api/unsubscribe): Email links point atGET /unsubscribewith signeduser,type, and 64-character hextokenquery parameters. Laravel redirects to the SPA settings page (/settings/notifications?unsubscribe=1&...), where an authenticated user confirms viaPOST /api/unsubscribe. The API call is exempted from CSRF protection so the flow can complete without bootstrapping a Sanctum session first. Optional body fields:channel(email,in_app,telegram; defaultemail) andscope(all,type; defaultall). The PWA service worker denylists/unsubscribeso the redirect reaches Laravel instead of the SPA shell.
These exceptions are intentional and ensure the generated client focuses on the /api/* routes documented in the OpenAPI spec.