Responses
Response, JsonResponse, RedirectResponse and StreamedResponse, and picking a status code that says what happened.
new Response(?string $content = '', int $status = 200, array $headers = [])Every controller ends in a Response. The object carries a body, a status code and a set of headers, and Symfony sends exactly what it holds. The subclasses are conveniences that prefill parts of it.
| Class | Constructor |
|---|---|
Response | (?string $content = '', int $status = 200, array $headers = []) |
JsonResponse | (mixed $data = null, int $status = 200, array $headers = [], bool $json = false) |
RedirectResponse | (string $url, int $status = 302, array $headers = []) |
StreamedResponse | (callable|iterable|null $callbackOrChunks = null, int $status = 200, array $headers = []) |
Status codes
Use the constants on Response: HTTP_CREATED, HTTP_NO_CONTENT, HTTP_SEE_OTHER, HTTP_UNPROCESSABLE_ENTITY. They read better than bare numbers and they are the same integers. A created resource is 201 with a Location header. A successful delete is 204. A failed form is 422, not 200 with an error in the body.
Large bodies
Response holds its whole body in memory. StreamedResponse does not: you give it a callback that echoes, or an iterable of strings, and Symfony sends each piece as it arrives. Accepting an iterable was added in Symfony 7.3; before that the argument had to be a callable. setChunks(iterable $chunks) sets the same thing after construction.
What trips people up
Headers go on the headers property, a ResponseHeaderBag: $response->headers->set('Content-Disposition', ...). Setting a header on the response object itself does nothing, because there is no such method.
Example
// src/Controller/ExportController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
final class ExportController extends AbstractController
{
#[Route('/export/articles.csv', name: 'app_export_articles', methods: ['GET'])]
public function articles(): StreamedResponse
{
// Passing an iterable of strings was added in Symfony 7.3.
// Each yielded chunk is sent as it is produced, so memory stays flat.
return new StreamedResponse($this->csvLines(), Response::HTTP_OK, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="articles.csv"',
]);
// Longhand with a callback, identical output:
// $response = new StreamedResponse(status: Response::HTTP_OK);
// $response->setCallback(function (): void {
// foreach ($this->csvLines() as $line) {
// echo $line;
// }
// });
}
/**
* @return iterable<string>
*/
private function csvLines(): iterable
{
yield "id,title\n";
yield "1,Hinges and hooks\n";
yield "2,Latches\n";
}
}When to use it
- Return 201 and a Location header after creating something over an API.
- Stream a CSV or log export without holding the whole file in memory.
- Serve a stored PDF as a download with a filename the user sees.
- Pass through JSON that is already encoded, without double-encoding it.
More examples
201 with a Location header, and 204
POST /comments returns 201 with {"id":17} and a Location header of /comments/17; DELETE /comments/17 returns 204 with no body. The third argument of json() is the headers array, which is where Location belongs. Clients and caches act on these codes, so a write endpoint that always answers 200 throws away information the protocol already carries.
// src/Controller/CommentController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class CommentController extends AbstractController
{
#[Route('/comments', name: 'app_comment_create', methods: ['POST'])]
public function create(): JsonResponse
{
$id = 17; // ... after storing the comment
return $this->json(
['id' => $id],
Response::HTTP_CREATED,
// Where the new thing now lives. generateUrl() returns a string.
['Location' => $this->generateUrl('app_comment_show', ['id' => $id])],
);
}
#[Route('/comments/{id<\d+>}', name: 'app_comment_show', methods: ['GET'])]
public function show(int $id): JsonResponse
{
return $this->json(['id' => $id, 'body' => 'Useful, thanks.']);
}
#[Route('/comments/{id<\d+>}', name: 'app_comment_delete', methods: ['DELETE'])]
public function delete(int $id): Response
{
// ... delete it
return new Response(null, Response::HTTP_NO_CONTENT);
}
}JSON you already hold as a string
GET /reports/daily returns 200 with the body {"generated":"2026-01-04","orders":128} and a Content-Type of application/json. The commented-out mistake is the one people actually ship: the response becomes "{\"generated\":...}", a JSON string containing JSON, and the client's parser gives back text instead of an object.
// src/Controller/CachedPayloadController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class CachedPayloadController extends AbstractController
{
#[Route('/reports/daily', name: 'app_report_daily', methods: ['GET'])]
public function daily(): JsonResponse
{
// Imagine this string came from a cache or a file, already encoded.
$json = '{"generated":"2026-01-04","orders":128}';
// Marks the content as JSON instead of re-encoding it.
return JsonResponse::fromJsonString($json, Response::HTTP_OK);
// The same thing through the constructor:
// return new JsonResponse($json, Response::HTTP_OK, [], true);
// Wrong: this sends a quoted, escaped string as the whole document.
// return new JsonResponse($json);
}
}Handing over a file
Both actions return 200 with the PDF bytes; the first downloads as invoice-7.pdf, the second opens in the browser's viewer. BinaryFileResponse streams from disk rather than loading the file into memory, and it supports Range requests, which matters for anything large. The path is assembled from a project parameter and an integer placeholder, because concatenating a user-supplied name here is a directory traversal.
// src/Controller/InvoiceDownloadController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class InvoiceDownloadController extends AbstractController
{
#[Route('/invoices/{id<\d+>}/pdf', name: 'app_invoice_pdf', methods: ['GET'])]
public function pdf(int $id): Response
{
// A fixed directory plus a validated integer: no user string in the path.
$path = sprintf('%s/var/invoices/%d.pdf', $this->getParameter('kernel.project_dir'), $id);
// Shorthand for: new BinaryFileResponse($path) plus a disposition header.
return $this->file($path, sprintf('invoice-%d.pdf', $id));
}
#[Route('/invoices/{id<\d+>}/pdf/inline', name: 'app_invoice_pdf_inline', methods: ['GET'])]
public function inline(int $id): Response
{
$path = sprintf('%s/var/invoices/%d.pdf', $this->getParameter('kernel.project_dir'), $id);
$response = new BinaryFileResponse($path);
// INLINE renders in the browser; ATTACHMENT forces a download.
$response->setContentDisposition(
HeaderUtils::DISPOSITION_INLINE,
sprintf('invoice-%d.pdf', $id),
);
return $response;
}
}
Discussion