Building a Production REST API with Symfony 7 and API Platform 3
π―π΅ Kotoba means
βvocabularyβ in Japanese.
The app helps readers
look up words they find
in manga panels.
I needed a REST API in a weekend.
Not a toy β a real one. Authentication, pagination, filtering, OpenAPI docs, something an iOS app could actually talk to. The backend for Kotoba, a Japanese vocabulary app I was building for manga readers. Users import a volume, tap on a word they donβt know, and the app saves it to a personal vocabulary list. Simple concept; non-trivial data model.
Iβd built APIs in Laravel before, and I knew raw Symfony could be verbose. But a colleague had mentioned API Platform in passing: βitβs like Rails but for REST, and it actually respects HTTP.β I gave it a weekend. Six weeks later it was running on Heroku with real users. This is that story β the good parts and the parts Iβd do differently.
The Stack
Why not Laravel? I know
Laravel well β but API
Platformβs JSON-LD
support and built-in
OpenAPI generation
were hard to say no to.
Before I get into the code, hereβs every piece of the puzzle. Click any layer to learn what it does and why I chose it:
API Platform in Five Minutes
#[ApiResource] is the
single attribute that
turns a Doctrine entity
into a full REST API.
No controllers, no
routes, no serializers.
The thing that genuinely surprised me about API Platform was the surface area of the boilerplate. Here is the entire Manga entity that gives you GET /api/mangas, POST /api/mangas, GET /api/mangas/{id}, PUT, PATCH, DELETE, pagination, filtering, and OpenAPI documentation:
// src/Entity/Manga.php namespace App\Entity; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource( normalizationContext: ['groups' => ['manga:read']], denormalizationContext: ['groups' => ['manga:write']], paginationItemsPerPage: 20, )] #[ApiFilter(SearchFilter::class, properties: ['title' => 'partial'])] #[ORM\Entity(repositoryClass: MangaRepository::class)] class Manga { #[ORM\Id, ORM\GeneratedValue, ORM\Column] #[Groups(['manga:read'])] public ?int $id = null; #[ORM\Column(length: 255)] #[Groups(['manga:read', 'manga:write'])] public string $title = ''; #[ORM\Column(length: 255, nullable: true)] #[Groups(['manga:read', 'manga:write'])] public ?string $englishTitle = null; #[ORM\Column(length: 512, nullable: true)] #[Groups(['manga:read', 'manga:write'])] public ?string $coverImageUrl = null; // Computed, read-only β see N+1 section for how this is populated #[Groups(['manga:read'])] public int $totalVocabularyCount = 0; /** @var Collection<int, Volume> */ #[ORM\OneToMany(targetEntity: Volume::class, mappedBy: 'manga', cascade: ['persist'])] #[Groups(['manga:read'])] public Collection $volumes; }
Thatβs it. Run symfony server:start and you have a fully functioning JSON-LD API. Hit /api and you get the Hydra entrypoint. Hit /api/docs and you get a Swagger UI with every endpoint documented.
The full domain model β Volume, Page, Vocabulary, KnownWord β follows the same pattern. Each entity gets #[ApiResource] and #[Groups] annotations; API Platform handles the rest.
Docker Setup
The entrypoint trick β
running migrations on
container start β saves
me from remembering
to run them manually
after every git pull.
My docker-compose.yml has three services: app (PHP-FPM), nginx, and mysql. The entrypoint.sh runs database migrations before the process starts. Zero-config onboarding for new contributors.
# docker-compose.yml version: '3.9' services: app: build: . volumes: - .:/var/www/html environment: DATABASE_URL: "mysql://kotoba:secret@mysql:3306/kotoba?serverVersion=8.0" JWT_SECRET_KEY: "%kernel.project_dir%/config/jwt/private.pem" JWT_PUBLIC_KEY: "%kernel.project_dir%/config/jwt/public.pem" JWT_PASSPHRASE: "change_me_in_prod" depends_on: mysql: condition: service_healthy nginx: image: nginx:1.25-alpine ports: - "8080:80" volumes: - .:/var/www/html - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf mysql: image: mysql:8.0 environment: MYSQL_DATABASE: kotoba MYSQL_USER: kotoba MYSQL_PASSWORD: secret MYSQL_ROOT_PASSWORD: rootsecret healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 10
#!/bin/sh # docker/entrypoint.sh set -e echo "Waiting for database..." until php bin/console doctrine:query:sql "SELECT 1" >/dev/null 2>&1; do sleep 1 done echo "Running migrations..." php bin/console doctrine:migrations:migrate --no-interaction exec php-fpm
healthcheck on the MySQL service prevents a race condition where PHP-FPM starts before MySQL is ready to accept connections. Without it, the first container startup after docker compose up fails about 30% of the time.JWT Authentication
LexikJWTBundle handles
key generation, signing,
and verification. I only
write the User entity
and a login endpoint
returning the token.
Kotoba uses LexikJWTAuthenticationBundle. After a user POSTs their credentials to /api/auth/login, they receive a signed JWT. Every subsequent request includes it in the Authorization: Bearer <token> header.
The login controller is trivial β LexikJWT handles the heavy lifting:
// src/Controller/AuthController.php #[Route('/api/auth/login', methods: ['POST'])] public function login( Request $request, UserRepository $users, UserPasswordHasherInterface $hasher, JWTTokenManagerInterface $jwt ): JsonResponse { $data = json_decode($request->getContent(), true); $user = $users->findOneByEmail($data['email'] ?? ''); if (!$user || !$hasher->isPasswordValid($user, $data['password'] ?? '')) { return $this->json(['error' => 'Invalid credentials'], 401); } return $this->json(['token' => $jwt->create($user)]); }
Securing a resource operation is a single attribute:
#[ApiResource( operations: [ new GetCollection(security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER') and object.user == user"), new Post(security: "is_granted('ROLE_USER')"), new Delete(security: "is_granted('ROLE_USER') and object.user == user"), ] )] #[ORM\Entity] class KnownWord { /* ... */ }
Interactive JWT Decoder
Paste any JWT token below to inspect its claims. This is useful for debugging β I used it constantly during development to verify exp, iat, and custom claims.
π JWT Inspector
The N+1 Query Problem
The N+1 problem is when
you load a list of N rows
and then fire one extra
query per row. 100 manga
= 101 queries. Doctrine
lazy-loads by default.
This bit me on the manga list endpoint. Each Manga has a totalVocabularyCount β the total number of vocabulary words across all its volumes and pages. My first implementation computed it via a PHP property that triggered a lazy-load for each manga. With 50 manga in the list, I was firing 51 queries.
The fix was a DQL subquery in the repository, fetching everything in one shot.
The problem (N+1):
// The naive approach β DO NOT do this in a list endpoint // Each call to $vocab->count() triggers a new SELECT public function getTotalVocabularyCount(): int { $count = 0; foreach ($this->volumes as $vol) { foreach ($vol->pages as $page) { $count += $page->vocabularies->count(); // β lazy query } } return $count; }
The fix (1 query):
// src/Repository/MangaRepository.php public function findAllWithVocabularyCount(): array { return $this->createQueryBuilder('m') ->addSelect('( SELECT COUNT(v.id) FROM App\Entity\Vocabulary v JOIN v.page p JOIN p.volume vol WHERE vol.manga = m ) AS HIDDEN totalVocabularyCount') ->getQuery() ->getResult(); }
HIDDEN keyword in DQL makes the computed value available during hydration but excludes it from the raw result array. You still need a custom hydrator or a StateProvider to map it back onto the entity property.Use the visualizer below to feel the difference:
β‘ N+1 Query Visualizer
Serialization Groups
Without serialization
groups, API Platform
serializes everything
Doctrine loads β including
internal fields, relations,
and anything marked#[Ignore].
The #[Groups] attribute is how you control which fields appear in which context. It seems like overhead at first, but itβs the thing Iβd enforce from day one on any real project.
Flip the cards below to see what the API response looks like with and without groups:
{
"id": 1,
"title": "Yotsuba&!",
"volumes": [...],
"createdAt": "2024-01-01",
"updatedAt": "2024-03-15",
"__initializer__": null,
"__cloner__": null,
"__isInitialized__": true,
"password": null,
"roles": []
}
{
"@context": "/api/contexts/Manga",
"@id": "/api/mangas/1",
"@type": "Manga",
"id": 1,
"title": "Yotsuba&!",
"englishTitle": "Yotsuba&!",
"coverImageUrl": "https://...",
"totalVocabularyCount": 842
}
{
"id": 99,
"word": "ι£γΉγ",
"reading": "γγΉγ",
"meaning": "to eat",
"page": {
"id": 12,
"pageNumber": 4,
"volume": {
"id": 3,
"manga": {
"id": 1,
...deeply nested...
}
}
}
}
{
"@id": "/api/vocabularies/99",
"@type": "Vocabulary",
"id": 99,
"word": "ι£γΉγ",
"reading": "γγΉγ",
"meaning": "to eat",
"page": "/api/pages/12"
}
The right side shows a clean, intentional contract. The page relation is an IRI β the client can fetch it if they need it, or ignore it. No surprise circular references, no leaked Doctrine proxy internals.
Heroku + RDS Deployment
Herokuβs ephemeral
filesystem was the
biggest gotcha. Any
uploaded file survives
only until the dyno
restarts β which happens
at least once a day.
The path from βworks on my machineβ to stable production took five days. Click each milestone to see what went wrong and how I fixed it:
π Deployment Journey
Custom Operations
Custom operations are
the escape hatch. When
the standard CRUD
operations donβt fit,
you define exactly the
HTTP contract you want.
Standard CRUD covers most of Kotoba, but one endpoint needed custom logic: GET /api/vocabulary/common β the most common words the authenticated user hasnβt learned yet. This drives the βStudyβ tab in the iOS app.
// src/Entity/Vocabulary.php β custom collection operation #[ApiResource( operations: [ new GetCollection(), new Get(), new GetCollection( uriTemplate: '/vocabulary/common', controller: CommonVocabularyController::class, security: "is_granted('ROLE_USER')", paginationEnabled: true, openapiContext: [ 'summary' => 'Get most common unknown words for the current user', 'description' => 'Returns vocabulary words ordered by frequency across all manga, excluding words the user already knows.', ] ), ] )] class Vocabulary { /* ... */ }
// src/Controller/CommonVocabularyController.php class CommonVocabularyController extends AbstractController { public function __invoke( VocabularyRepository $repo, Request $request ): array { $user = $this->getUser(); $page = (int) $request->query->get('page', 1); $limit = (int) $request->query->get('itemsPerPage', 20); return $repo->findCommonUnknownForUser($user, $page, $limit); } }
// src/Repository/VocabularyRepository.php public function findCommonUnknownForUser( User $user, int $page = 1, int $limit = 20 ): array { return $this->createQueryBuilder('v') ->leftJoin( 'App\Entity\KnownWord', 'kw', 'WITH', 'kw.vocabulary = v AND kw.user = :user' ) ->andWhere('kw.id IS NULL') ->setParameter('user', $user) ->orderBy('v.frequencyRank', 'ASC') ->setFirstResult(($page - 1) * $limit) ->setMaxResults($limit) ->getQuery() ->getResult(); }
API Platform generates
OpenAPI docs automatically
β your iOS and web clients
always have up-to-date
documentation without
any extra work.
The openapiContext key on the custom operation means /api/docs shows a proper description for this endpoint. My iOS developer (me, wearing a different hat) never had to ask βwhat does this endpoint return?β
Lessons Learned: Interactive Checklist
The messenger component
is perfect for async jobs
like AI-based vocabulary
extraction from manga
images. Dispatch a message
in the controller, process
it in a background worker.
Six weeks of real usage crystallised these into non-negotiable practices. Click each item to mark it as acknowledged:
-
Use #[Groups] from day one Without serialization groups, API Platform serializes everything Doctrine loads β including proxy internals, circular references, and fields you never intended to expose. Retrofitting groups onto an existing API is painful.
-
Disable ACLs on S3 buckets New AWS buckets have ACLs disabled by default. If you get
AccessControlListNotSupported, removeACL: public-readfrom your Flysystem config and use bucket policies instead for public read access. -
Use hash_equals() in custom auth, never === String comparison with
===is vulnerable to timing attacks.hash_equals()runs in constant time regardless of where the strings diverge. Symfony'sisPasswordValid()does this for you β but any custom comparison must too. -
Add X-Total-Count header for iOS pagination API Platform returns a
hydra:totalItemsfield in JSON-LD, but iOS's URLSession clients often find it easier to read a plainX-Total-Countheader. Add anEventSubscriberonkernel.responseto set it. -
Use ApiTestCase for integration tests
ApiPlatform\Symfony\Bundle\Test\ApiTestCasegives you a real HTTP client that boots the kernel, runs migrations against a test DB, and lets you assert JSON-LD responses. Way more confidence than mocked unit tests alone. -
Profile with Symfony Profiler before optimizing The Symfony web profiler shows every Doctrine query per request with execution time. I found three N+1 problems I didn't know about until I looked at the profiler. Don't guess β measure first.
-
JSON-LD @id fields are worth the verbosity At first the
@context,@id,@typefelt over-engineered. But the iOS app uses@idURIs to cache and invalidate resources. Relations become self-describing β the client navigates the API without hardcoded IDs.
Where It Stands Today
Next: replacing the manual
vocabulary entry with an
ML pipeline. Symfony
Messenger dispatches
an image to a FastAPI
service that runs manga
OCR and returns words.
Kotobaβs API currently serves about 40 active beta users. The stack has been stable since that fifth day. Response times are well under 100ms for most endpoints β the DQL optimizations helped a lot.
If I were starting again, Iβd make two changes: use API Platformβs State Providers instead of custom controllers from the start (they compose better with the platformβs pagination and filtering), and add the Symfony Profiler to the dev environment on day one rather than week three.
API Platform is the fastest way Iβve found to get from a domain model to a production-quality REST API in PHP. The JSON-LD layer feels heavy until you understand it β then it feels inevitable. The #[ApiResource] attribute is genuinely magic, until you need to step outside it, at which case the escape hatches (custom operations, state providers, event subscribers) are clean and well-documented.
The iOS app ships next month. The API will be ready.