Content Negotiation#
Serve the best format (JSON, text, XML) from one handler. Webrick’s negotiation middleware inspects client headers and exposes decisions so Response::auto() can do the right thing without branching all over your code.
Overview#
Client expresses preferences via
Accept,Accept-Language, etc.Negotiation middleware parses headers and sets request attributes (e.g.,
locale).Your handler returns
Response::auto($request, $data)and lets the library pickContent-Type.
Enable negotiation#
Add NegotiationMiddleware in your pre-global stack (front controller):
$preGlobal = [
// ...hardening, limits, throttle, cookies...
\Infocyph\Webrick\Middleware\NegotiationMiddleware::class,
// ...response cache, validators...
];
Configuration#
Configure NegotiationMiddleware with supported types and locales:
use Infocyph\Webrick\Middleware\NegotiationMiddleware;
$preGlobal[] = new NegotiationMiddleware(
produces: [
'+json', // Vendor JSON types (application/vnd.api+json)
'application/json',
'text/html',
'text/plain',
'application/xml'
],
charsets: ['utf-8'], // Supported charsets
locales: ['en', 'es', 'fr', 'de', 'ja'], // Supported locales
localeFallback: 'en' // Default when no match
);
Per-Route Media Type Restrictions#
Use the #[Produces] attribute to limit what a route can return:
use Infocyph\Webrick\Router\Definition\Attribute\Get;
use Infocyph\Webrick\Router\Definition\Attribute\Produces;
#[Get('/api/data.xml', name: 'api.data.xml')]
#[Produces(types: ['application/xml', 'text/xml'])]
public function getXml(Request $r): Response {
$data = '<data><item>1</item><item>2</item></data>';
return Response::create($data, 200, [
'Content-Type' => 'application/xml; charset=UTF-8'
]);
}
#[Get('/api/data.json', name: 'api.data.json')]
#[Produces(types: ['application/json'])]
public function getJson(Request $r): Response {
return Response::json(['items' => [1, 2]]);
}
// Flexible endpoint (negotiates)
#[Get('/api/data', name: 'api.data')]
#[Produces(types: ['application/json', 'application/xml'])]
public function getData(Request $r): Response {
$data = ['items' => [1, 2]];
$negotiated = $r->getAttribute('negotiated.type');
if (str_contains($negotiated, 'xml')) {
$xml = '<data><item>1</item><item>2</item></data>';
return Response::create($xml, 200, [
'Content-Type' => 'application/xml; charset=UTF-8'
]);
}
return Response::json($data);
}
Content Negotiation Strategies#
1. Extension-Based (Explicit)#
// Client explicitly chooses format via extension
Route::get('/users/{id}.json', [UserController::class, 'show'], 'users.show.json');
Route::get('/users/{id}.xml', [UserController::class, 'showXml'], 'users.show.xml');
Route::get('/users/{id}.html', [UserController::class, 'showHtml'], 'users.show.html');
Pros: Clear, cacheable, no ambiguity Cons: More routes to maintain
2. Accept Header (Standard)#
// Single endpoint, negotiates via Accept header
Route::get('/users/{id}', [UserController::class, 'show'], 'users.show');
// In controller
public function show(Request $r, int $id): Response {
$user = $this->repo->find($id);
return Response::auto($r, $user); // Negotiates
}
Pros: RESTful, single endpoint Cons: Harder to cache (need Vary: Accept), clients must set header correctly
3. Query Parameter (Fallback)#
Route::get('/users/{id}', function(Request $r, int $id) {
$format = $r->query('format', 'json'); // ?format=xml
$user = ['id' => $id, 'name' => 'Alice'];
return match($format) {
'xml' => Response::create(
'<user><id>' . $id . '</id><name>Alice</name></user>',
200,
['Content-Type' => 'application/xml']
),
'json' => Response::json($user),
default => Response::json(['error' => 'Unsupported format'], 400)
};
});
Pros: Simple, URL-based caching Cons: Non-standard, pollutes query namespace
Optional: pass constructor/config to customize supported media types/locales if your middleware supports it.
Use Response::auto()#
use Infocyph\Webrick\Request\Request;
use Infocyph\Webrick\Response\Response;
Route::get('/auto', function (Request $r) {
$payload = ['msg'=>'hello', 'time'=>time()];
return Response::auto($r, $payload); // JSON by default, or text/XML when appropriate
});
What it generally does:
Prioritizes
application/jsonfor API-like Accept headers.Falls back to
text/plain; charset=UTF-8for generic clients (*/*).Can emit XML/HTML when requested and allowed by your settings.
Forcing a specific type (when needed)#
You can always bypass negotiation and respond explicitly:
// Always JSON
return Response::json(['ok'=>true]);
// Always text
return Response::plaintext("hello\n", 200);
// XML explicitly
return Response::create('<note>hello</note>', 200, ['Content-Type'=>'application/xml']);
Language & locale#
If your negotiation middleware resolves a locale, it is available as an attribute:
$locale = $r->getAttribute('locale') ?? 'en';
Use this to select translations, date formats, or numeric formatting. Keep translation concerns outside the router for separation of concerns.
Error formats#
For APIs, stick to a consistent error envelope regardless of Accept; negotiation can still adjust Content-Type, but structure should remain predictable:
return Response::json(['error'=>['code'=>'E_INPUT','message'=>'Invalid field']], 422);
If you need multiple representations (e.g., HTML form errors vs JSON API errors), either:
Split endpoints by UI/API, or
Branch on
Acceptonce per handler and keep logic minimal.
Caveats & best practices#
Don’t trust
Acceptblindly: some clients send*/*.Response::auto()handles this gracefully.Compression interplay: content type affects compression choices; the compression middleware decides what’s safe/beneficial.
Caching: if you vary response by
Acceptor language, ensure your Vary header includesAccept/Accept-Language. A post-global VaryAccumulatorMiddleware can help manage this consistently.
Example:
return Response::auto($r, ['ok'=>true])
->withAddedHeader('Vary', 'Accept, Accept-Language');
(If you use Vary accumulator middleware, it can append these for you.)
Example: one handler, many faces#
Route::get('/greet/{name}', function (Request $r, string $name) {
$data = ['greet' => "Hello, {$name}"];
return Response::auto($r, $data);
});
curl -H "Accept: application/json" /greet/Hasan→{"greet":"Hello, Hasan"}Browser with generic
Accept→ plain text or JSON depending on defaultsCustom client
Accept: application/xml→<greet>Hello, Hasan</greet>(if XML enabled)
Troubleshooting#
Symptom |
Cause |
Fix |
|---|---|---|
Always JSON even with |
Negotiation middleware not enabled |
Add |
Cache seems wrong between JSON/text |
Missing |
Use Vary accumulator or set |
Wrong locale used |
Header/param mismatch or default |
Inspect |
Checklist#
Enable
NegotiationMiddlewarein pre-globalsPrefer
Response::auto($request, $data)for polymorphic handlersSet/accumulate
Varyheaders when format/language changes outputKeep a consistent error shape across formats for APIs