The Request Object
Request exposes the query string, body, headers, cookies and files as separate bags, none of which you may trust.
$request->query->get(string $key, mixed $default = null): string|int|float|bool|nullThe Request object is one PHP object wrapping one HTTP request. Symfony builds it from the superglobals before your controller runs, so you never touch $_GET or $_POST yourself. Ask for a Request argument and you get it.
Each source of data has its own bag, and keeping them apart is the point of the design.
| Property | Holds |
|---|---|
$request->query | The query string, after the ? |
$request->request | The POST body, when it is form-encoded |
$request->attributes | Values Symfony put there, including route placeholders |
$request->headers | Headers, looked up case-insensitively |
$request->cookies | Cookies the client sent |
$request->files | Uploaded files, as UploadedFile objects |
Reading a value
get() returns a scalar or your default. The typed readers — getInt(), getBoolean(), getString(), getEnum(), getDigits() — are thin wrappers that validate the raw value, and getInt() and getBoolean() are built on filter(). In Symfony 8.1, getInt('page', 1) is filter('page', 1, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR | \FILTER_NULL_ON_FAILURE]), with a BadRequestException thrown when that filtering yields null. Array values need all('tags'); get() refuses them.
What trips people up
query and request get swapped constantly. Remember that request is the request body. A JSON body never lands there, because it is not form-encoded. Use $request->getPayload(), added in Symfony 6.3, which returns the POST bag or the decoded JSON, or $request->toArray() for the raw decoded array.
Everything in every bag came from the client. Validate it, bind it as a query parameter, escape it on output.
Example
// src/Controller/SearchController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class SearchController extends AbstractController
{
#[Route('/search', name: 'app_search', methods: ['GET', 'POST'])]
public function __invoke(Request $request): JsonResponse
{
// query = the query string: /search?term=hinge&page=2
$term = $request->query->getString('term');
// getInt is shorthand for filter() with FILTER_VALIDATE_INT.
$page = $request->query->getInt('page', 1);
// $page = $request->query->filter(
// 'page',
// 1,
// \FILTER_VALIDATE_INT,
// ['flags' => \FILTER_REQUIRE_SCALAR],
// );
// getPayload() covers a form-encoded body and a JSON body alike.
$sort = $request->getPayload()->getString('sort', 'relevance');
// Header names are case-insensitive here.
$accept = $request->headers->get('Accept-Language', 'en');
return $this->json([
'term' => $term, // still untrusted text; the template escapes it
'page' => $page,
'sort' => $sort,
'locale' => $accept,
]);
}
}When to use it
- Read a page number and a search term from the query string with a safe default.
- Accept a JSON body on a POST endpoint without reaching for json_decode by hand.
- Check an incoming header, such as Accept-Language or a custom token.
- Inspect which route matched, for a debug endpoint or a log line.
More examples
A JSON body, and why arrays need all()
POST /articles with that JSON body returns 201 with the echoed fields. $request->request would be empty here, because the body is JSON rather than form-encoded. Calling $payload->get('tags') on a nested array throws a BadRequestException, so all() is the method you want for any list or nested structure.
// src/Controller/ArticleWriteController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ArticleWriteController extends AbstractController
{
#[Route('/articles', name: 'app_article_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
// {"title":"Latches","tags":["hardware","doors"]}
$payload = $request->getPayload();
$title = $payload->getString('title');
// get() only ever returns a scalar, so an array must be read with all().
$tags = $payload->all('tags');
// ... store the article
return $this->json(
['title' => $title, 'tags' => $tags],
Response::HTTP_CREATED,
);
}
}Files and cookies
POST /avatar with a multipart form returns 202, or 400 when the field is missing. The files bag gives UploadedFile objects rather than raw arrays, and the type check matters because a missing field yields null. The comment on getClientOriginalName() is the habit to form early: a filename is user input like any other.
// src/Controller/AvatarController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class AvatarController extends AbstractController
{
#[Route('/avatar', name: 'app_avatar_upload', methods: ['POST'])]
public function upload(Request $request): Response
{
$file = $request->files->get('avatar');
if (!$file instanceof UploadedFile) {
return new Response('No file sent.', Response::HTTP_BAD_REQUEST);
}
// getClientOriginalName() is attacker-controlled; never build a path from it.
// guessExtension() reads the detected MIME type instead.
$extension = $file->guessExtension() ?? 'bin';
$theme = $request->cookies->getString('theme', 'light');
return new Response(
sprintf('Accepted .%s avatar, theme %s.', $extension, $theme),
Response::HTTP_ACCEPTED,
['Content-Type' => 'text/plain; charset=utf-8'],
);
}
}Route placeholders live in the attributes bag
GET /invoices/INV-0007/debug returns 200 with {"route":"app_invoice_debug","reference":"INV-0007",...}. The attributes bag is the framework's own scratch space, which is why route placeholders appear there and not in query. Knowing this explains how a controller argument named $reference gets filled in the first place.
// src/Controller/InvoiceDebugController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class InvoiceDebugController extends AbstractController
{
#[Route('/invoices/{reference}/debug', name: 'app_invoice_debug', methods: ['GET'])]
public function debug(Request $request): JsonResponse
{
return $this->json([
// Symfony writes the matched route and its placeholders here.
'route' => $request->attributes->get('_route'),
'reference' => $request->attributes->get('reference'),
'method' => $request->getMethod(),
'path' => $request->getPathInfo(),
]);
}
}
Discussion