Build a JSON API endpoint in four steps.
composer require gearsite/apiSymfony Flex registers the bundle for you. The recipe writes this line in config/bundles.php:
Gearsite\Api\GearsiteJSONAPIBundle::class => ['all' => true],NOTE: If you do not use Flex, add that line yourself. Flex configures a package only from its recipe. This package has one.
Run the maker command:
php bin/console make:api-serializerOpen the new file. Then declare the fields and relationships:
use Gearsite\Api\Serializer\Descriptor\Attribute;
use Gearsite\Api\Serializer\Descriptor\Relationship;
use Gearsite\Api\Serializer\DoctrineSerializer;
use Gearsite\Api\Serializer\SerializerRegistry;
class ArticleSerializer extends DoctrineSerializer {
public function __construct(
ArticleRepository $repository,
private readonly SerializerRegistry $registry,
) {
parent::__construct(type: 'article', repository: $repository);
}
public function getFields(): array {
return [
'title' => Attribute::native(),
'body' => Attribute::native(),
'created-at' => Attribute::native('createdAt'),
];
}
public function getRelationships(): array {
return [
'comments' => Relationship::toMany($this->registry->get(CommentSerializer::class)),
];
}
}Attribute::native() maps a field to the entity property with the same name. To map a property with a different name, pass that name: Attribute::native('createdAt').
Get related serializers from SerializerRegistry. Serializers often reference each other, and the registry prevents circular constructor dependencies.
NOTE: For computed fields, secured fields, and relationship options, read serializers.md.
Generator is abstract. Extend it once and implement both abstract methods:
use Gearsite\Api\Query\Generator;
class QueryGenerator extends Generator {
protected function buildPagination(): void {
$page = $this->request->getPageParameters();
$number = max(1, intval($page['number'] ?? 1));
$size = max(1, intval($page['size'] ?? 25));
$this->qb->setFirstResult(($number - 1) * $size)->setMaxResults($size);
}
protected function buildFilter(): void {
// Read $this->request->getFilterParameters() and apply them to $this->qb.
}
}The JSON API specification leaves pagination and filtering to the implementation. Both methods are required, even when one of them does nothing.
CAUTION: Bind every filter value as a query parameter. Never concatenate request input into DQL.
use Gearsite\Api\Factory\DocumentFactory;
use Gearsite\Api\Factory\QueryFactory;
use Gearsite\Api\Model\Document;
use Gearsite\Api\Request\SymfonyRequest;
#[Route('/articles')]
class ArticleController extends AbstractController {
public function __construct(
private readonly ArticleSerializer $serializer,
private readonly DocumentFactory $documentFactory,
private readonly QueryFactory $queryFactory,
private readonly EntityManagerInterface $em,
private readonly Security $security,
) {}
#[Route('', methods: ['GET'])]
public function index(SymfonyRequest $request): JsonResponse {
$results = $this->queryFactory->create(QueryGenerator::class, serializer: $this->serializer, request: $request, security: $this->security)->build()->execute();
$document = $this->documentFactory->create(data: $results, serializer: $this->serializer, request: $request);
return new JsonResponse(data: $document, status: 200, headers: ['Content-Type' => Document::MEDIA_TYPE]);
}
#[Route('/{id}', methods: ['GET'])]
public function show(int $id, SymfonyRequest $request): JsonResponse {
$article = $this->queryFactory->create(QueryGenerator::class, serializer: $this->serializer, request: $request, security: $this->security, id: $id)->build()->execute();
$document = $this->documentFactory->create(data: $article, serializer: $this->serializer, request: $request);
return new JsonResponse(data: $document, status: 200, headers: ['Content-Type' => Document::MEDIA_TYPE]);
}
#[Route('', methods: ['POST'])]
public function create(SymfonyRequest $request): JsonResponse {
$article = new Article();
$this->serializer->deserialize($request->getContent(), $article);
$this->em->persist($article);
$this->em->flush();
$document = $this->documentFactory->create(data: $article, serializer: $this->serializer, request: $request);
return new JsonResponse(data: $document, status: 201, headers: ['Content-Type' => Document::MEDIA_TYPE]);
}
#[Route('/{id}', methods: ['PATCH'])]
public function update(int $id, SymfonyRequest $request): JsonResponse {
$article = $this->serializer->getRepository()->find($id);
$this->serializer->deserialize($request->getContent(), $article);
$this->em->flush();
$document = $this->documentFactory->create(data: $article, serializer: $this->serializer, request: $request);
return new JsonResponse(data: $document, status: 200, headers: ['Content-Type' => Document::MEDIA_TYPE]);
}
#[Route('/{id}', methods: ['DELETE'])]
public function delete(int $id): Response {
$article = $this->serializer->getRepository()->find($id);
$this->em->remove($article);
$this->em->flush();
return new Response(status: 204);
}
}The container injects SymfonyRequest for you. It extends the Symfony Request class and adds the JSON API query parameters.
deserialize() applies a request body onto the entity with PATCH semantics. It writes only the fields that the body contains.
# List articles
curl http://localhost/articles
# Sparse fieldset and include
curl "http://localhost/articles?fields[article]=title,body&include=comments"
# Create
curl -X POST http://localhost/articles \
-H "Content-Type: application/vnd.api+json" \
-d '{"data":{"type":"article","attributes":{"title":"Hello","body":"World"}}}'
# Update
curl -X PATCH http://localhost/articles/1 \
-H "Content-Type: application/vnd.api+json" \
-d '{"data":{"type":"article","id":"1","attributes":{"title":"Updated"}}}'- serializers.md — computed attributes, secured fields, custom links and meta
- atomic-operations.md — batch writes in one transaction
- JSON API specification — the full query parameter reference