JSON Endpoints
Return JsonResponse, map a request body onto a typed DTO, pick the right status code, and keep the controller thin.
public function create(#[MapRequestPayload] CreateArticleInput $input): JsonResponseA JSON endpoint is a controller that returns data rather than a page. The caller is another program, so the body is machine-readable and the status code carries the outcome.
Returning the response
JsonResponse is a response whose body is JSON and whose content type is application/json. $this->json($data, $status, $headers, $context) is shorthand for building one: it returns the same JsonResponse object, serializing through the Serializer when that component is installed and through json_encode() when it is not. new JsonResponse($data, $status) is the longhand and always uses json_encode(). For an array of scalars the two are identical.
Reading the body
#[MapRequestPayload] on a controller argument deserializes the request body into the class named by that argument's type, then validates the result. A DTO — a data transfer object, a small class whose only job is to carry values — gives you typed input instead of digging through arrays. Invalid input never reaches your code: the response is 422 before the method body runs.
Status codes
200 for a representation, 201 for something created, 204 for success with no body, 404 when the thing is not there, 422 when the body parsed but failed validation, 415 when the content type is not one you accept.
What trips people up
$request->query holds the query string; $request->request holds the submitted body. Reach for the wrong one and you get an empty bag, not an error. With #[MapRequestPayload] you rarely touch either.
Example
// src/Controller/Api/ArticleController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Dto\CreateArticleInput;
use App\Service\ArticleCreator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/articles', name: 'api_articles_')]
final class ArticleController extends AbstractController
{
// The service is injected, not pulled out of the container.
public function __construct(private readonly ArticleCreator $creator)
{
}
#[Route('', name: 'create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateArticleInput $input): JsonResponse
{
// $input is already validated. Nothing here checks for a blank title.
$article = $this->creator->create($input->title, $input->body, $input->state);
// Shorthand.
return $this->json(['id' => $article->getId()], Response::HTTP_CREATED);
// Longhand, same object and same body for an array of scalars:
// return new JsonResponse(['id' => $article->getId()], Response::HTTP_CREATED);
}
}
// POST /api/articles with {"title":"Routing","body":"..."} returns 201 and
// the body {"id":7}. The same request with an empty title returns 422 and
// never enters create(). Form-encoded and multipart bodies are mapped too,
// so 415 only comes from a content type Symfony cannot resolve to a format,
// or from one excluded by acceptFormat.When to use it
- Accept a JSON body from a mobile client and reject a missing field with 422 before any of your code runs.
- Return 201 and the new identifier after a successful POST.
- Expose a read-only collection endpoint whose filters and page size come from a typed query-string object.
- Keep a controller down to routing and status codes by moving the work into an injected service.
More examples
The input DTO
The class named in the controller argument's type is where the JSON body lands. Properties are filled through the constructor, so promoted readonly properties work and the object is never half-built. The constraints live beside the fields they guard, which is why the controller needs no validation code. Defaults matter: a key missing from the body falls back to the default, and then the constraint decides whether that is acceptable.
// src/Dto/CreateArticleInput.php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final class CreateArticleInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 180)]
public readonly string $title = '',
#[Assert\NotBlank]
public readonly string $body = '',
#[Assert\Choice(choices: ['draft', 'review'])]
public readonly string $state = 'draft',
) {
}
}Filters from the query string
<code>#[MapQueryString]</code> maps the whole query string onto one object, so pagination and filter parameters get types and constraints like any other input. The default value handles the empty query string. Note the status code override: query-string validation failures are a 404 unless you say otherwise, which surprises clients expecting 422 from the rest of your API.
// src/Controller/Api/ArticleListController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Dto\ArticleFilters;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
final class ArticleListController extends AbstractController
{
public function __construct(private readonly ArticleRepository $articles)
{
}
#[Route('/api/articles', name: 'api_articles_list', methods: ['GET'])]
public function __invoke(
// Without the override, a bad filter would answer 404.
#[MapQueryString(validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY)]
ArticleFilters $filters = new ArticleFilters(),
): JsonResponse {
// The repository fetch-joins the author, so the loop inside the
// serializer does not fire one query per row.
$rows = $this->articles->findPublished($filters->tag, $filters->limit);
return $this->json($rows);
}
}
// GET /api/articles?tag=http&limit=10 returns 200 with a JSON array.
// GET /api/articles with no query string uses the default ArticleFilters.Not found, and no content
Two patterns worth copying. <code>createNotFoundException()</code> returns an exception to throw, so the missing-row case is one expression and the controller stays linear. And 204 means empty, so the response type is <code>Response</code>, not <code>JsonResponse</code> — a 204 with a body is a response some HTTP clients refuse to parse.
// src/Controller/Api/ArticleItemController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Repository\ArticleRepository;
use App\Service\ArticleArchiver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/articles/{id}', requirements: ['id' => '\d+'])]
final class ArticleItemController extends AbstractController
{
public function __construct(
private readonly ArticleRepository $articles,
private readonly ArticleArchiver $archiver,
) {
}
#[Route('', name: 'api_articles_show', methods: ['GET'])]
public function show(int $id): JsonResponse
{
$article = $this->articles->find($id) ?? throw $this->createNotFoundException();
return $this->json($article, Response::HTTP_OK, [], ['groups' => ['article:read']]);
}
#[Route('', name: 'api_articles_delete', methods: ['DELETE'])]
public function delete(int $id): Response
{
$this->archiver->archive($id);
// 204 carries no body. JsonResponse would write the four bytes
// "null" into a response that is defined as empty.
return new Response(null, Response::HTTP_NO_CONTENT);
}
}
// GET /api/articles/7 returns 200 with the article JSON; /api/articles/999
// returns 404. DELETE /api/articles/7 returns 204 with an empty body.
Discussion