The Request Object

Request exposes the query string, body, headers, cookies and files as separate bags, none of which you may trust.

Syntax$request->query->get(string $key, mixed $default = null): string|int|float|bool|null

The 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.

PropertyHolds
$request->queryThe query string, after the ?
$request->requestThe POST body, when it is form-encoded
$request->attributesValues Symfony put there, including route placeholders
$request->headersHeaders, looked up case-insensitively
$request->cookiesCookies the client sent
$request->filesUploaded 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

Example · php
// 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.

Example · php
// 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.

Example · php
// 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.

Example · php
// 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

  • Be the first to comment on this lesson.