Content Negotiation in REST APIs Beyond JSON
AI generated
{ }
GET
Content Negotiation · HTTP
Content Negotiation Beyond JSON
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.

14 min read Content Negotiation · Accept Header Symfony Serializer

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

11. FAQ: Content Negotiation: The Essentials at a Glance

1Does every REST API need to support content negotiation?
No, for pure JSON APIs without export requirements the extra effort usually isn't justified. It pays off mainly with several real consumer formats.
2What's the difference between Accept and Content-Type?
Accept describes what the client wants to receive, Content-Type describes what format a sent request body actually has.
3How do I handle a missing Accept header?
A sensible default, usually application/json, should be used when no Accept header is set, instead of returning an error.
4Can I combine content negotiation with API versioning?
Yes, both mechanisms are independent. Some APIs even encode the version directly in the Accept header, such as application/vnd.api.v2+json.
5Why not just use separate endpoints per format?
Separate endpoints duplicate filter, sort, and pagination logic and easily drift apart. Content negotiation keeps this logic centralized.
6Does the Symfony Serializer support PDF directly?
No, PDF needs its own rendering library like Dompdf. Format detection itself can still run through the same negotiation mechanism.
7How do I test content negotiation automatically?
With integration tests that call the same endpoint with different Accept headers and check both the response's Content-Type and body structure.
8What happens with multiple Accept values and quality factors?
The server should choose the highest supported quality value, for example text/csv;q=0.9 over application/json;q=0.5, if both are supported.
9Should CSV contain the same fields as JSON?
Where technically possible, yes, for consistency. Nested structures that can't be sensibly represented in CSV should be explicitly documented.
10Is content negotiation performance-relevant?
Format detection itself is trivially fast. Actual serialization into more complex formats like PDF can, however, take noticeably longer than JSON.