0

Content negotiation — Accept header, multiple representations

Intermediate5 min read·eng-13-015

Concept

Content negotiation — the HTTP mechanism where client and server agree on the best representation of a resource. The client says what it can handle; the server picks the best match.

Why it exists: The same resource (/users/42) can be represented in multiple formats: JSON, XML, HTML, CSV. Content negotiation lets one endpoint serve different formats without different URLs.

Request headers for content negotiation:

  • Accept: Preferred response format. Accept: application/json, text/html;q=0.9. q=0.9 is a quality/preference value (0.0–1.0, default 1.0). Higher q = more preferred.
  • Accept-Language: Preferred language. Accept-Language: en-US, fr;q=0.8.
  • Accept-Encoding: Preferred compression. Accept-Encoding: gzip, deflate, br. Server compresses response if it supports the encoding.
  • Accept-Charset: Preferred character encoding. Rarely used today — UTF-8 is universal.

Response headers:

  • Content-Type: The actual format of the response.
  • Content-Language: The language of the response.
  • Content-Encoding: The compression used (e.g., gzip). Client must decompress.
  • Vary: Tells caches which request headers the response depends on. Vary: Accept-Encoding means the cache should store separate versions for gzip vs non-gzip.

Server-driven vs client-driven:

  • Server-driven: Server picks the format (most common). Server reads Accept and chooses.
  • Client-driven: Server returns a list of options (300 Multiple Choices); client picks one. Rarely used.

Practical use in APIs:

  • Most REST APIs only support JSON. Content negotiation is rarely implemented.
  • But Accept-Encoding: gzip IS used — Nginx gzips responses automatically.
  • APIs that serve both browsers and clients might check Accept: text/html vs application/json.

Code Example

php
<?php
// ACCEPT HEADER — client states format preference
// Request: Accept: application/json, application/xml;q=0.8, text/html;q=0.5
// Means: prefer JSON, then XML, then HTML

// Reading Accept in Laravel
Route::get('/users/{id}', function (int $id, Request $request) {
    $user = User::findOrFail($id);

    // Check what the client accepts
    if ($request->wantsJson() || $request->accepts('application/json')) {
        return response()->json($user);
    }

    if ($request->accepts('text/html')) {
        return view('users.show', compact('user'));
    }

    if ($request->accepts('text/csv')) {
        return response()
            ->streamDownload(function () use ($user) {
                echo "id,name,email\n{$user->id},{$user->name},{$user->email}";
            }, 'user.csv', ['Content-Type' => 'text/csv']);
    }

    // Client requested a format we don't support
    return response('Not Acceptable', 406); // 406 Not Acceptable
});

// respond() helper — auto-negotiation
Route::get('/reports', function (Request $request) {
    $data = Report::all();
    return $request->expectsJson()
        ? response()->json($data)
        : view('reports.index', compact('data'));
});

// ACCEPT-ENCODING — compression (usually handled by Nginx/webserver)
// Client: Accept-Encoding: gzip, deflate, br
// Server: Content-Encoding: gzip
// Response body is compressed — client decompresses

// In PHP — gzip output manually (if not handled by Nginx)
Route::get('/large-data', function () {
    $data     = json_encode(User::all()->toArray());
    $accepts  = request()->header('Accept-Encoding', '');
    if (str_contains($accepts, 'gzip')) {
        return response(gzencode($data), 200, [
            'Content-Encoding' => 'gzip',
            'Content-Type'     => 'application/json',
            'Vary'             => 'Accept-Encoding',
        ]);
    }
    return response($data, 200, ['Content-Type' => 'application/json']);
});

// VARY header — tells caches which request headers affect the response
Route::get('/users', function (Request $request) {
    $format = $request->accepts('application/json') ? 'json' : 'html';
    return response($format === 'json' ? json_encode([]) : '<html></html>')
        ->header('Content-Type', $format === 'json' ? 'application/json' : 'text/html')
        ->header('Vary', 'Accept'); // cache MUST store separately for json and html
});

// ACCEPT-LANGUAGE — localization
Route::get('/welcome', function (Request $request) {
    $lang = $request->getPreferredLanguage(['en', 'fr', 'de']) ?? 'en';
    App::setLocale($lang);
    return response()->json(['message' => __('welcome')])->header('Content-Language', $lang);
});