Capstone: JSON API

The spec

The second capstone exposes the same blog as JSON: list posts, read one, create, delete. No templates this time, only status codes and JSON bodies. Four endpoints cover the whole surface.

GET /api/posts - list, GET /api/posts/id - read one

POST /api/posts - create, DELETE /api/posts/id - delete

API routes

API routes look like page routes but answer with JsonResponse instead of templates. Map each endpoint to one controller method, keep the method short, and let the status code tell the client what happened.

use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; class PostApiController { #[Route("/api/posts", methods: ["GET"])] public function list(PostRepository $posts): JsonResponse { $rows = []; foreach ($posts->findAll() as $post) { $rows[] = ["id" => $post->getId(), "title" => $post->getTitle()]; } return new JsonResponse($rows); } }

Serialize groups

Entities carry more fields than the API should show. Mark the public fields with a serialize group and enable only that group per endpoint, so private columns never leak into answers.

use Symfony\Component\Serializer\Attribute\Groups; class Post { #[Groups(["post:read"])] private string $title = ""; }

return $this->json($post, context: ["groups" => ["post:read"]]);

Test the endpoints

Test the API like a client would: call the endpoint, check the status, decode the JSON, assert the shape. Three assertions per endpoint catch most regressions before deploy.

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class PostApiTest extends WebTestCase { public function testListReturnsJson(): void { $client = static::createClient(); $client->request("GET", "/api/posts"); $this->assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($data); } }

Next: Modern Symfony tour

Article author: Arthur Isaev