libero/content-negotiation-bundle
Content negotiation bundle for PHP applications. Inspects Accept headers to select the right response format and serializer, so a single endpoint can serve JSON, XML, or HTML. Pluggable negotiators and clean middleware integration for Libero-based Laravel projects.
This release (v0.1.0) makes the package usable for the two most common negotiation setups in a Laravel app: deciding a negotiated value from the route definition (#2) and from a URL path segment (#3). Previously you were effectively limited to whatever ambient signal the package read by default (typically a header), which meant most real apps had to hand-roll a wrapper anyway.
Minimal steps:
php artisan vendor:publish --tag=<package>-config. The config file is the first thing to read — the resolver/strategy list and its ordering is the whole mental model of this package.web (and api) group — before anything that reads the negotiated value, notably SetLocale, session start, and any URL-defaults middleware./fr/..., /v2/...) where the middleware reads the segment, validates it against a supported-value list, and exposes the result to the rest of the request lifecycle. Then layer route-level declarations on a single endpoint to see how the two interact.Path-based negotiation (#3) — the natural fit for locale prefixes and API versioning:
Route::prefix('{locale}')
->whereIn('locale', config('app.supported_locales'))
->middleware('negotiate')
->group(function () {
Route::get('/pricing', PricingController::class);
});
Constrain the parameter with whereIn so unsupported values 404 at the routing layer instead of silently falling through to a default. Inside the group, feed the resolved value into URL::defaults(['locale' => $locale]) so route() keeps emitting the prefix without threading it through every call.
Route-level negotiation (#2) — declare acceptable values on the route itself, which is the cleanest option when negotiation is per-endpoint rather than global (different API versions exposing different representations, an endpoint that only speaks one format):
Route::get('/export', ExportController::class)
->negotiates(['format' => ['csv', 'json']]);
Treat this as the highest-priority signal: an explicit per-route declaration should win over an implicit path segment or header, because it is the most local and most intentional piece of configuration.
Keep precedence deterministic. With three sources now in play (route declaration, path segment, header/default), write down the order and assert it in tests. A sane default is route > path > header > config default, but the important thing is that it's fixed and documented in your own codebase, not inferred.
Publish the resolved value once. Have the middleware stash the result on the request (attributes) or in a small value object bound to the container, and read it from there in controllers, FormRequests, and Blade/Inertia shared props. Avoid re-parsing the path in multiple places.
Testing. Feature-test the paths and routes directly rather than unit-testing resolvers in isolation — the interesting bugs live in the interaction between route parameters and middleware ordering:
$this->get('/fr/pricing')->assertOk();
$this->assertSame('fr', app()->getLocale());
$this->get('/xx/pricing')->assertNotFound();
SetLocale or a session/cookie middleware runs first, it will overwrite the negotiated value and you'll spend an hour debugging a resolver that was working fine. Register negotiation explicitly rather than relying on the order of a service provider's pushMiddleware.{locale} (or {version}) segment sitting in a global prefix constrains every route in the group, including existing ones. Audit php artisan route:list after enabling it and make sure no existing route already binds a parameter of that name.URL::defaults() and any locale set during a request persist. Reset them in a teardown or in the middleware's terminate/cleanup path — otherwise test order affects results and Octane requests bleed one user's locale into the next.URL::signedRoute, some auth redirects, mailables) needs the negotiated value passed explicitly or URL::defaults set in that context too.Cache::remember on a controller action is the classic way to serve French HTML to an English visitor. Push the value into the key and set an appropriate Vary header at the edge.composer.json and read the diff before bumping.X-Negotiated-* response header in local env is worth the five minutes.How can I help you explore Laravel packages today?