HTTP Methods and Formats
Restrict a route to specific HTTP methods, and match on host, scheme or response format.
#[Route('/articles.{_format}', methods: ['GET'], format: 'html', schemes: ['https'])]A path is only half of a request. GET /articles/7 asks for an article; DELETE /articles/7 asks to remove it. The methods argument on #[Route] says which HTTP methods a route accepts. Two routes may share a path if their methods differ, which is how one URL serves both a read and a write.
Why it matters
GET and HEAD are safe methods: they are expected to change nothing. Browsers, proxies and crawlers prefetch and re-issue them freely. A route that deletes or updates something must therefore not accept GET, or a crawler will eventually trigger it. Restricting methods is the cheapest protection you have against that.
If the path matches but the method does not, the response is 405 Method Not Allowed, not 404, and it carries an Allow header listing what the path does accept.
Beyond methods
Routes can also require a host (host: 'api.example.com') or a scheme (schemes: ['https'], which redirects an http request rather than refusing it). A condition takes an expression evaluated against the request for cases the other options cannot express.
Formats
A {_format} placeholder captures an extension, so /articles.json and /articles.html reach one method. The shorthand format: 'html' is the same as defaults: ['_format' => 'html']; pair it with a requirement so only formats you implement match.
What trips people up
HTML forms can only send GET or POST. A route limited to PUT or DELETE is reachable from an API client but not from a plain form.
Example
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ArticleController extends AbstractController
{
// Safe methods only: nothing here changes state.
#[Route('/articles/{id<\d+>}', name: 'app_article_show', methods: ['GET', 'HEAD'])]
public function show(int $id): Response
{
return new Response(sprintf('Article %d', $id));
}
// Same path, different methods: a separate route, not a conflict.
#[Route('/articles/{id<\d+>}', name: 'app_article_update', methods: ['PUT', 'PATCH'])]
public function update(int $id): Response
{
return new Response(null, Response::HTTP_NO_CONTENT);
}
// format: 'html' is shorthand for defaults: ['_format' => 'html'].
#[Route(
'/articles.{_format}',
name: 'app_article_index',
methods: ['GET'],
format: 'html',
requirements: ['_format' => 'html|json'],
)]
public function index(): Response
{
return new Response('<h1>Articles</h1>');
}
}
When to use it
- Serve a read endpoint and an update endpoint from the same /articles/{id} path.
- Keep a destructive action off GET so crawlers and prefetchers cannot fire it.
- Return JSON or HTML from one method based on a .json or .html extension in the URL.
More examples
Host and scheme as part of the match
Host matching lets one application serve a site and an API on different domains without a second deployment, and `schemes` keeps a route off plain http. `GET https://api.example.com/status` returns 200 with `{"status":"ok"}`; the same path on the main domain is a 404.
<?php
// src/Controller/ApiStatusController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class ApiStatusController
{
// Matches only on the api subdomain, and only over https.
// An http request to this path is redirected to https
// rather than refused.
#[Route(
'/status',
name: 'api_status',
host: 'api.example.com',
schemes: ['https'],
methods: ['GET'],
)]
public function __invoke(): JsonResponse
{
return new JsonResponse(['status' => 'ok']);
}
}
A condition for what the other options cannot express
`condition` reaches the whole request, so it covers header-based and context-based matching. Keep it rare: conditions are evaluated at match time and cannot be reversed into a URL, so a route with a condition is harder to reason about than one with a distinct path.
<?php
// src/Controller/ContactController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ContactController extends AbstractController
{
// condition is an expression evaluated against the request.
// Here the route only matches requests that accept JSON.
#[Route(
'/contact',
name: 'app_contact_api',
methods: ['POST'],
condition: "request.headers.get('Accept') matches '/application\\/json/'",
)]
public function submit(): Response
{
return new Response(null, Response::HTTP_ACCEPTED);
}
}
Methods in YAML, pipe-separated
`methods: GET|HEAD` in YAML is the same as `methods: ['GET', 'HEAD']` in an attribute; the pipe form is a convenience of the YAML loader. Two entries sharing a path with different methods is the normal shape of a read/write endpoint pair.
# config/routes.yaml
app_article_show:
path: /articles/{id}
controller: App\Controller\ArticleController::show
methods: GET|HEAD
requirements:
id: '\d+'
app_article_update:
path: /articles/{id}
controller: App\Controller\ArticleController::update
methods: PUT|PATCH
requirements:
id: '\d+'
Discussion