SuperAPI AI Directory

symfony-yoandev-http

General & other

View repository

Updated 10-16 08:07

About this skill

HTTP layer

Tier: core — with the simplifications symfony-yoandev-standards lists under What the rules do not require — no Input DTO for a plain GET, one Output DTO per shape.

Controllers, routing, request input, responses, forms, templates.

A controller is a translation layer: HTTP in, a service call, a representation out. It holds no rule. That is not tidiness — it is what makes the rule testable without a kernel, and what lets the same service serve an HTML page and a JSON endpoint without either one being a special case.

The shape of a controller

One class per resource, class-level route prefix, one action per use case.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Service\ShelfReader; use Symfony\Bridge\Twig\Attribute\Template; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Routing\Attribute\Route;

#[Route('/books', name: 'book_')] final class BookController extends AbstractController { public function __construct(private readonly ShelfReader $shelf) { }

#[Route('', name: 'index', methods: ['GET'])]
#[Template('book/index.html.twig')]
public function index(): array
{
    return ['books' => $this->shelf->shelf()];        // list<BookView>
}

#[Route('/{id}', name: 'show', methods: ['GET'], requirements: ['id' => '\d+'])]
#[Template('book/show.html.twig')]
public function show(int $id): array
{
    return ['book' => $this->shelf->book($id)];       // BookView, not a Book entity
}

}


**The template receives an output DTO, never the entity.** And the controller does not
load entities as a habit: the service takes an id and owns the 404 (`BookNotFound`,
`symfony-yoandev-architecture`). The EntityValueResolver (`Book $book`) has one job — resolving
the subject a `#[IsGranted]` voter needs before the action runs — and even then the
action passes `$book->getId()` on; the service's `find()` is answered by the identity
map. Letting a resolved entity reach Twig is how `book.author.name` in a loop becomes an
N+1 that appears in no PHP file.

`#[Route('/books', name: 'book_')]` on the class plus `name: 'index'` on the action
gives the route name `book_index`. Rejected alternative: one invokable class per action.
It reads well in isolation and badly in aggregate — the shared prefix, the shared
dependencies and the shared security rules get copied into every file, and a resource's
surface is no longer visible in one place.

### What never goes in a controller

| Found in a controller | Where it belongs | Why |
|---|---|---|
| `if` on a business condition | a service | The rule needs a unit test, not an HTTP request |
| `EntityManagerInterface`, `flush()` | a service (which calls the repository) | `flush()` is one call per use case, owned by the service |
| `QueryBuilder`, DQL, `findBy` with criteria | a repository method named after the intent | Rule 3; deptrac enforces it when adopted. See `symfony-yoandev-doctrine` |
| An entity in a JSON response **or a template** | an output DTO in `src/Dto/Output/` | An entity is a mapping, not an API contract; it leaks on every column you add, and lazy-loads from inside rendering |
| `$request->request->get('title')` | an input DTO + `#[MapRequestPayload]` | Untyped, unvalidated, invisible to PHPStan |
| `try { … } catch (\Throwable)` around a service call | `#[WithHttpStatus]` on the exception | The status belongs to the exception, once, not to every caller |
| A `<script>` or a business rule in the template | a Stimulus controller (see `symfony-yoandev-frontend`) | |

An action that is more than about ten lines is almost always holding something that
belongs elsewhere.

## Routing

Attributes only, on the action. Three things earn their keep:

- **`requirements:`** — `['id' => '\d+']` stops `/books/new` from matching `/books/{id}`
  and turns a junk id into a clean 404. Symfony 8.1 also coerces and validates typed
  route parameters before calling the controller (a non-numeric value for an `int $id`
  becomes a 404); on 6.4–8.0 the same request raises a `TypeError` and returns 500, so
  the requirement is what protects you.
- **`methods:`** — an action that only handles POST should say so, otherwise a GET
  reaches it and fails somewhere less obvious.
- **`priority:`** — only when two patterns genuinely overlap. Needing it usually means a
  requirement is missing.

**JSON routes are prefixed `api_` and live in a separate controller from their HTML
equivalent.** `BookController` and `ApiBookController`: same resource, same service, two
translations. They diverge in everything the framework treats differently — response
format, error rendering, statelessness, authentication, caching — and merging them ends
in `if ($request->getPreferredFormat() === 'json')` in every action.

## Getting the request in

Never read `$request` by hand when an attribute can do it. The attribute types the
value, validates it, and turns a bad request into a proper status code before your code
runs.

```php
#[Route('/reviews', name: 'create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateReviewInput $input): Response
Attribute Reads Since Failure status
#[MapRequestPayload] body (JSON, XML, form) → DTO 6.3 422
#[MapQueryString] whole query string → DTO 6.3 404
#[MapQueryParameter] one query parameter 6.3 404
#[MapRequestHeader] one header 8.1 400
#[MapUploadedFile] one uploaded file 7.1 422
#[MapEntity] an entity by something other than its id 6.2 404

The 404 defaults surprise people: an invalid ?page=abc reads as "no such page", which is defensible for an HTML listing and wrong for an API. Pass validationFailedStatusCode: 422 on API routes.

Input DTOs live in src/Dto/Input/ and carry the validation constraints. Not every action has one: a GET whose only input is a route parameter takes int $id and stops there — the DTO exists for a payload or a query string that must be validated, and symfony-yoandev-standards lists the other simplifications the rules allow. Details, version fallbacks, uploaded files inside a DTO, variadic payloads, dynamic validation groups, and why #[MapEntity(expr:)] is rejected: references/input-mapping.md.

Getting the response out

The action returns data; an attribute decides the representation.

#[Route('/books/{id}', name: 'api_book_show', methods: ['GET'], requirements: ['id' => '\d+'])]
#[Serialize]                                   // Symfony 8.1+
public function show(int $id): BookView        // an output DTO, never an entity
{
    return $this->shelf->book($id);
}
#[Route('/books/new', name: 'book_new', methods: ['GET', 'POST'])]
#[Template('book/new.html.twig')]
public function new(Request $request): array|Response
{
    // array → rendered; Response → returned as-is
}

array|Response is the whole idiom: #[Template] renders the array, and a redirect short-circuits it because Symfony only dispatches the view event when the controller did not return a Response.

Two behaviours verified in vendor/symfony/twig-bridge/EventListener/TemplateAttributeListener.php: a FormInterface in the returned array is converted to a FormView for you (pass $form, not $form->createView()), and if that form is submitted and invalid the status becomes 422 — what Turbo needs to re-render the page with its errors instead of ignoring the reply.

Output DTOs live in src/Dto/Output/. #[Serialize], serializer attributes, the list envelope and the float trap below: references/json-api.md.

The float trap

json_encode(4.0) produces 4. A client that parsed averageRating as a float yesterday gets an integer today, purely because the average landed on a round number. Verified against this vendor tree:

new JsonResponse(['average' => 4.0])   {"average":4}
$this->json($dto)                      {"average":4}     ← forces the JsonResponse flags
#[Serialize] on the action             {"average":4.0}   ← JsonEncode preserves it

AbstractController::json() overrides the serializer's context with JsonResponse::DEFAULT_ENCODING_OPTIONS, which omits JSON_PRESERVE_ZERO_FRACTION. #[Serialize] passes only its own context, so the default survives. Two fixes, in references/json-api.md.

Errors

API errors are RFC 7807 Problem Details. Symfony's ProblemNormalizer already produces the type / title / status / detail body, with a violations list for validation failures — but a client sending Accept: application/problem+json gets an HTML error page, because no encoder is registered for the problem format and the error renderer silently falls back. A six-line encoder fixes it, ready to copy: assets/ProblemJsonEncoder.php → src/Serializer/. The reasoning is in references/json-api.md.

Business exceptions carry their own status:

#[WithHttpStatus(409)]
final class ShelfIsFull extends \RuntimeException
{
}

Thrown anywhere, it becomes a 409 with no catch in the controller and no kernel listener. It is inherited by subclasses, and ignored if the exception already implements HttpExceptionInterface.

Where this skill stops: API Platform

Everything above builds a JSON API by hand — input DTOs, #[Serialize], Problem Details, the pagination envelope. That is deliberate and it works well for a handful of endpoints you control end to end.

It stops being the right call at a point worth naming, because past it you are reimplementing API Platform badly. Redirect to it when any of these is true:

  • the API has more than a handful of resources, each needing list, filter, sort and paginate — you would be writing the same four things again per resource;
  • it is consumed by clients you do not control, or by generated clients;
  • you need a documented contract — OpenAPI, a schema, anything a consumer can read. This suite has no answer for that, and needing one is itself the signal;
  • you need several formats, or content negotiation beyond JSON;
  • filtering and sorting are becoming a query language.

API Platform gives all of it natively, including RFC 7807 errors — the encoder this skill ships exists precisely because the hand-written path does not get them for free.

This suite does not cover API Platform. It is a different stack, not a library you add: it brings its own resource model, its own pagination shape (Hydra by default, not the items + meta envelope decided here), its own filters. Adopting it overrides several decisions made here, and that is fine — it is the right trade at that scale.

Say so plainly rather than quietly building a worse version. And do not mix the two on one project: two ways of exposing a resource is worse than either.

Forms

A FormType lives in src/Form/, and its data_class is an input DTO, never an entity. Binding a form to an entity means the request writes to a managed object before anything validates it, and any flush() in the same request persists it.

Three rules with the same root — the FormType describes fields, nothing else: no buttons (they belong to the template, which knows the context), no constraints (they are on the DTO, which is also what #[MapRequestPayload] validates — one source of truth for both entry points), and one action renders and processes, GET and POST on the same route.

That last rule has one extra branch worth knowing: an action can answer the POST with a Turbo Stream ($request->setRequestFormat(TurboBundle::STREAM_FORMAT), then render) when the submission has to refresh several disjoint zones instead of navigating — keeping the 422 on the invalid path and a 303 fallback for requests that did not ask for a stream. references/forms-and-twig.md has it; symfony-yoandev-frontend arbitrates whether the interaction should be a stream response, a Live Component or a pushed stream.

empty_data, readonly DTOs, #[UniqueEntity] on the input DTO, #[IsCsrfTokenValid] and the Twig conventions: references/forms-and-twig.md.

When it does not do what you expect

Symptom Cause
500 instead of 404 on a bad {id} Missing requirements: ['id' => '\d+'] (and Symfony < 8.1)
404 on an invalid query parameter The default for #[MapQueryString] / #[MapQueryParameter]; set validationFailedStatusCode: 422
415 Unsupported Media Type on POST No Content-Type on the request, so #[MapRequestPayload] cannot pick a format
The DTO argument is null The body was empty and the argument is nullable or has a default; use mapWhenEmpty: true
Could not resolve the "$x" controller argument The argument is untyped
An HTML error page on an API route Accept: application/problem+json with no encoder for the problem format
4.0 serialised as 4 $this->json() or JsonResponse; use #[Serialize] or pass the flag
The form is always invalid, no message Constraints are on the entity, not on the DTO the form is bound to
#[UniqueEntity] never fires It is on the entity; it must be on the validated input DTO
Form submits but the DTO is unchanged The DTO is readonly with no empty_data callable
#[IsGranted] seems to run after mapping It does not — gate attributes run first, by design

Reference files

File When to read it
references/input-mapping.md Mapping anything from the request: payloads, query strings, headers, uploads, entities, validation groups
references/json-api.md Writing or fixing a JSON endpoint: #[Serialize], serializer attributes, floats, Problem Details, list envelopes
references/forms-and-twig.md Building a form, answering a submission with a Turbo Stream, or writing/restructuring templates
assets/ProblemJsonEncoder.php Copy to src/Serializer/ on any project exposing a JSON API — without it, Accept: application/problem+json returns HTML

Adjacent skills: symfony-yoandev-doctrine (entities, repositories, queries), symfony-yoandev-security (authentication, voters, #[IsGranted]), symfony-yoandev-frontend (Stimulus, Turbo, Twig components), symfony-yoandev-performance (when #[Cache] is justified), symfony-yoandev-testing, and symfony-yoandev-storage — this skill stops at the valid UploadedFile; where it is written, how it is served back and what deletes it are that skill's.