How a single REST resource serves multiple formats via the Accept header
Most REST APIs serve exclusively JSON, even though the HTTP standard offers an elegant mechanism, content negotiation, to offer the same resource in multiple formats depending on what the client requests via the Accept header. For use cases like CSV exports, PDF reports, or legacy XML integrations, it's worth using this mechanism instead of separate endpoints per format.
Table of Contents
- 1. How content negotiation works via the Accept header
- 2. Implementing content negotiation with the Symfony Serializer
- 3. CSV as a practical export format for business users
- 4. Serving PDF and other binary formats via content negotiation
- 5. Fallback strategy for unsupported formats
- 6. Format query parameters as a pragmatic alternative
- 7. Avoiding different data models per format
- 8. Caching and the Vary header with multiple formats
- 9. Content negotiation formats at a glance
- 10. Summary
- 11. FAQ
1. How content negotiation works via the Accept header
The Accept header, sent by a client with every HTTP request, tells the server which media types the client can process and in what order of preference, expressed through quality values like application/json;q=0.9, text/csv;q=0.5. A server supporting content negotiation picks the matching response format based on this header, instead of offering only a single, hardcoded format per resource.
This mechanism is part of the HTTP standard itself (RFC 9110) and independent of the specific framework, meaning content negotiation can in principle be integrated into any REST API, regardless of whether it's built with Symfony, Express, or another framework.
Alongside the Accept header for the response format, related headers like Accept-Language and Accept-Charset handle language and character-set negotiation following the same basic principle, but are deliberately left out of this article, since the focus here is on the representation form of the data, not language or encoding.
2. Implementing content negotiation with the Symfony Serializer
Symfony's Serializer component supports several encoders out of the box (JsonEncoder, XmlEncoder, CsvEncoder), all operating on the same normalized data model, so a single controller action can pick the matching encoder based on the requested format without duplicating business logic. Format detection itself can happen through a dedicated content negotiation listener that parses the Accept header and writes the result into the _format request attribute.
It's important to clearly separate the negotiation process from plain serialization: negotiation decides WHICH format is used, the serializer decides HOW the data gets converted into that format. This separation keeps the controller code independent of the concrete output format.
An additional benefit of this separation shows up when adding new formats: a new encoder can be registered as a standalone service without touching existing controller actions, as long as the negotiation layer knows the new media type and correctly maps it to the matching encoder.
<?php
declare(strict_types=1);
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\SerializerInterface;
final class OrderExportController
{
private const SUPPORTED_FORMATS = [
'application/json' => 'json',
'text/csv' => 'csv',
'application/xml' => 'xml',
];
public function __construct(private readonly SerializerInterface $serializer) {}
public function export(Request $request, array $orders): Response
{
$accept = $request->headers->get('Accept', 'application/json');
$format = self::SUPPORTED_FORMATS[$accept] ?? 'json';
$content = $this->serializer->serialize($orders, $format);
$contentType = array_search($format, array_flip(self::SUPPORTED_FORMATS));
return new Response($content, 200, ['Content-Type' => $contentType]);
}
}
3. CSV as a practical export format for business users
A common, practical use case for content negotiation is a CSV export of the same resource that also serves as a JSON API, for example for business users who want to process order data further in Excel without maintaining a separate export function. Instead of a dedicated /export/csv endpoint with duplicated query and filter logic, an Accept: text/csv on the existing list endpoint is enough.
This reuse not only reduces code duplication but also keeps filter, sort, and pagination parameters consistent across formats, since the same underlying query logic is used. A user can get exactly the same filtered view both as JSON in their own application and as a CSV download.
For very large exports, it's also worth implementing the CSV path with streaming instead of full in-memory aggregation, so that exports with hundreds of thousands of rows don't exhaust the PHP process's available memory before the first row has even been sent to the client.
4. Serving PDF and other binary formats via content negotiation
Binary formats like PDF fit less naturally into the serializer approach, because they aren't a simple data-to-format mapping but require their own rendering logic (for example via a library like Dompdf or mPDF). Still, Accept: application/pdf can use the same content negotiation mechanism, with the controller branching to a separate rendering path once a PDF request is detected, instead of using the generic serializer.
This hybrid handling, unified negotiation logic for format detection but separate rendering paths for structured and binary formats, is common and pragmatic in practice, as long as the content negotiation layer itself stays consistent across all formats.
5. Fallback strategy for unsupported formats
If a client requests a format the API doesn't support, say Accept: application/yaml, the server should not silently fall back to JSON, but respond with HTTP 406 Not Acceptable to explicitly tell the client its desired format isn't available. The response body should ideally list the actually supported formats, so the client can adjust its request accordingly.
A silent fallback to JSON might seem more convenient short-term, but it hides real integration errors and can lead to a client wrongly assuming for a long time that its desired format is supported, when it's actually always getting JSON back.
6. Format query parameters as a pragmatic alternative
In practice, many APIs use an explicit query parameter like ?format=csv in addition to the Accept header, because Accept headers can't be set directly in browser URLs, and a user who wants to click a CSV export link directly doesn't have an HTTP client at hand to manipulate headers. This combination isn't a contradiction to pure HTTP content negotiation, but a pragmatic addition for directly clickable links.
It's important to define clear priority rules if both mechanisms are used simultaneously: an explicit query parameter should be able to override the Accept header, since it expresses a more deliberate, direct user intent than an Accept header that might have been set automatically by the browser.
7. Avoiding different data models per format
One pitfall in content negotiation is that different formats gradually deliver different, inconsistent field sets, for example because the CSV format for practical reasons maps fewer nested fields than JSON. This inconsistency surprises clients switching between formats and should be avoided through a unified, format-independent data model from which all formats are derived.
Where nested structures can't be sensibly represented in a flat format like CSV, that limitation should be explicitly documented instead of silently omitting fields, so users of the CSV export understand why certain information present in the JSON format is missing there.
8. Caching and the Vary header with multiple formats
Once a URL delivers different content depending on the Accept header, every intermediate cache (reverse proxy, CDN, browser cache) needs to know that the response depends on the Accept header, instead of serving a single cached version to all clients. The Vary header with the value Accept signals exactly that and instructs caches to keep separate cache entries per requested format.
Forgetting this header can lead to a subtle, hard-to-diagnose bug where a client that is the first to request a URL with Accept: text/csv accidentally writes the CSV response into the shared cache, and subsequent clients that actually wanted JSON also get served the CSV response. The Vary header is therefore practically mandatory when content negotiation is combined with active caching.
9. Content negotiation formats at a glance
The table below compares typical use cases for the most common formats.
| Format | Accept header | Typical use case |
|---|---|---|
| JSON | application/json | Default for programmatic clients and SPAs |
| CSV | text/csv | Export for Excel, business users, reporting |
| XML | application/xml | Legacy integrations, B2B systems requiring XML |
| application/pdf | Human-readable reports, invoices, certificates |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
Content Negotiation: The Essentials at a Glance
Accept header
Standard mechanism per RFC 9110 to communicate the desired response format per request.
Symfony Serializer
Multiple encoders (JSON, XML, CSV) on the same normalized data model reduce code duplication.
406 instead of silent fallback
Unsupported formats should be explicitly rejected instead of silently falling back to JSON.
Query parameter addition
?format=csv as a pragmatic addition for directly clickable links alongside the Accept header.