Negotiation Middleware#
Parses Accept, Accept-Language, Accept-Charset headers and resolves the best match for content negotiation.
Configuration#
use Infocyph\Webrick\Middleware\NegotiationMiddleware;
$preGlobal[] = new NegotiationMiddleware(
produces: [
'+json', // Vendor JSON types (application/vnd.api+json, etc.)
'application/json',
'text/html',
'text/plain',
'application/xml',
'text/xml'
],
charsets: ['utf-8', 'iso-8859-1'], // Supported charsets
locales: ['en', 'es', 'fr', 'de', 'ja'], // Supported locales
localeFallback: 'en' // Default when no match
);
Constructor Parameters#
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Supported media types |
|
|
|
Supported character encodings |
|
|
|
Supported locales (language codes) |
|
|
|
Default locale when client preference absent |
What It Does#
Parses
Acceptheader → finds best media type matchParses
Accept-Charset→ finds best charset matchParses
Accept-Language→ finds best locale matchAttaches results as request attributes:
negotiated.type→'application/json'negotiated.charset→'utf-8'locale→'en'
Using Negotiated Values#
In Handlers with Response::auto()#
use Infocyph\Webrick\Request\Request;
use Infocyph\Webrick\Response\Response;
Route::get('/data', function (Request $r) {
$data = ['items' => [1, 2, 3]];
// Automatically chooses format based on negotiation
return Response::auto($r, $data);
});
Client sends:
GET /data
Accept: application/xml
Response:
<data><items><item>1</item><item>2</item></items></data>
Manual Branching#
Route::get('/users', function (Request $r) {
$users = UserRepository::all();
$type = $r->getAttribute('negotiated.type');
return match(true) {
str_contains($type, 'json') => Response::json($users),
str_contains($type, 'xml') => Response::create(
$this->toXml($users),
200,
['Content-Type' => 'application/xml']
),
str_contains($type, 'html') => Response::create(
$this->renderHtml($users),
200,
['Content-Type' => 'text/html']
),
default => Response::json($users) // Fallback
};
});
Access Locale#
Route::get('/greeting', function (Request $r) {
$locale = $r->getAttribute('locale') ?? 'en';
$greetings = [
'en' => 'Hello',
'es' => 'Hola',
'fr' => 'Bonjour',
'de' => 'Guten Tag'
];
return Response::plaintext($greetings[$locale] ?? $greetings['en'], 200);
});
Per-Route Media Type Restrictions#
Use #[Produces] attribute to limit acceptable types:
use Infocyph\Webrick\Router\Definition\Attribute\{Get, Produces};
#[Get('/data.json', name: 'data.json')]
#[Produces(types: ['application/json'])]
public function getJson(): Response {
return Response::json(['format' => 'json']);
}
#[Get('/data.xml', name: 'data.xml')]
#[Produces(types: ['application/xml', 'text/xml'])]
public function getXml(): Response {
return Response::create('<data><format>xml</format></data>', 200, [
'Content-Type' => 'application/xml'
]);
}
#[Get('/data', name: 'data')]
#[Produces(types: ['application/json', 'application/xml', 'text/html'])]
public function getData(Request $r): Response {
$data = ['format' => 'flexible'];
return Response::auto($r, $data); // Negotiates
}
Behavior: If client requests unsupported type, returns 406 Not Acceptable.
Media Type Aliases#
The middleware recognizes common aliases:
+json → application/json
+xml → application/xml
Example:
Accept: application/vnd.api+json
Matches:
produces: ['+json', 'application/json']
Quality Values (q-factor)#
Client can specify preferences with quality values:
Accept: text/html, application/xml;q=0.9, */*;q=0.8
Priority:
text/html(q=1.0, implicit)application/xml(q=0.9)*/*(q=0.8, catch-all)
Middleware picks the highest q-value that you support.
Language Negotiation#
Accept-Language: fr-FR, fr;q=0.9, en;q=0.8, *;q=0.5
Matching:
Exact match:
fr-FRPrefix match:
frFallback:
en(if in yourlocales)Ultimate fallback:
localeFallback
Request Attributes Set#
Attribute |
Type |
Example |
Description |
|---|---|---|---|
|
|
|
Best media type match |
|
|
|
Best charset match |
|
|
|
Best locale match |
Interplay with Vary Header#
If you serve different content based on negotiation, always set Vary:
return Response::json($data)
->withHeader('Vary', 'Accept, Accept-Language');
Or use VaryAccumulatorMiddleware (post-global) to manage this automatically.
Error Handling (406 Not Acceptable)#
If middleware can’t satisfy client’s Accept header:
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
"error": "Cannot produce requested media type",
"acceptable": ["application/json", "text/html", "application/xml"]
}
Best Practices#
✅ Do#
List types in preference order
produces: ['application/json', 'text/html', 'application/xml'] // Prefers JSON if client accepts multiple
Use
Response::auto()for flexibilityreturn Response::auto($r, $data); // Adapts to client
Set
Varywhen content differs->withHeader('Vary', 'Accept, Accept-Language')
Provide sensible fallback
localeFallback: 'en' // Most common
❌ Don’t#
Don’t ignore negotiation results
// ❌ Always returns JSON regardless of Accept return Response::json($data); // ✅ Respects negotiation return Response::auto($r, $data);
Don’t support formats you can’t produce
// ❌ Says you support XML but can't generate it produces: ['application/json', 'application/xml'] // Handler only does: Response::json(...)
Don’t forget
Varyheader// ❌ Cacheable but varies by Accept return Response::json($data) ->withHeader('Cache-Control', 'public, max-age=60'); // ✅ Correct return Response::json($data) ->withHeader('Cache-Control', 'public, max-age=60') ->withHeader('Vary', 'Accept');
Troubleshooting#
Issue: Always returns JSON#
Cause: Handler doesn’t use Response::auto() or manual branching.
Fix:
// Before
return Response::json($data);
// After
return Response::auto($r, $data);
Issue: Wrong locale selected#
Cause: Locale not in locales array.
Debug:
$requested = $r->getHeaderLine('Accept-Language');
$resolved = $r->getAttribute('locale');
error_log("Requested: {$requested}, Resolved: {$resolved}");
Fix: Add missing locale:
locales: ['en', 'es', 'fr', 'de', 'ja', 'pt'] // Added 'pt'
Issue: 406 errors in production#
Cause: Client sends Accept header your app doesn’t support.
Solution: Add wildcard fallback:
produces: ['application/json', 'text/html', '*/*'] // Catch-all
Or handle 406 gracefully:
// In error handler
if ($status === 406) {
return Response::json(['error' => 'Not acceptable'], 406);
}
Summary#
Negotiation middleware is essential for:
✅ RESTful APIs serving multiple formats
✅ Internationalized applications
✅ Progressive enhancement (HTML → JSON for AJAX)
Key decisions:
Produces: List all formats you can generate
Locales: List all translations you support
Fallback: Choose the most common locale
Vary: Always set when content differs
Golden rule: If you negotiate, respect the result and set Vary.