liyu/dingo-serializer-switch
Per-request serializer switching for Dingo API in Laravel. Clients choose array, JSON, or custom serializers via header or query parameter, letting one API serve multiple response formats without config changes or extra controllers.
The package is a thin middleware that swaps the active Dingo API serializer for a single request. It does not replace Dingo's transformer; it just calls setSerializer() on the resolved Fractal adapter before your controller runs, and (in most setups) the framework resets it on the next request. Treat it as a request-scoped configuration toggle, not a full content-negotiation framework.
Minimal steps:
dingo/api install (the middleware is useless without Dingo's transformer bound):
composer require liyu/dingo-serializer-switch
config/dingo-serializer-switch.php) and the middleware class lives under Liyu\DingoSerializerSwitch\.... Publish config if offered:
php artisan vendor:publish --provider="Liyu\DingoSerializerSwitch\ServiceProvider"
app/Http/Kernel.php or, more commonly, as route middleware on your Dingle API route group:
// app/Http/Kernel.php
protected $routeMiddleware = [
// ...
'api.serializer' => \Liyu\DingoSerializerSwitch\...::class,
];
Then attach it where you already use api.group / api.auth:
$api->group(['middleware' => ['api.serializer']], function ($api) {
$api->get('users', 'UserController@index');
});
config/api.php (defaults.serializer) so the default stays stable. The middleware only overrides it when the switch condition triggers.Accept header, depending on how the package is configured:
# default serializer
curl https://api.example.com/users
# switched serializer (example: JSON API)
curl -H 'Accept: application/vnd.api+json' https://api.example.com/users
Compare the JSON shapes — you should see {"data": {...}} vs {"data": [{"id": "1", "type": "users", "attributes": {...}}]} style differences. If nothing changes, the middleware isn't in the stack or the config mapping is wrong.Attach per route group, not globally. Serializer switching is a client-facing contract; attaching it to all routes (including internal health checks and webhooks) creates noise. Use it on the API group(s) where multiple client shapes are actually supported.
Config-driven mapping. The package is designed around a name → serializer class map in its config, typically something like:
// config/dingo-serializer-switch.php
return [
'default' => 'array',
'serializers' => [
'array' => League\Fractal\Serializer\ArraySerializer::class,
'data' => League\Fractal\Serializer\DataArraySerializer::class,
'jsonapi' => League\Fractal\Serializer\JsonApiSerializer::class,
],
];
Keep the map explicit and never let clients pass an arbitrary FQCN — that would be an instantiation gadget.
Content negotiation via Accept. For a public API, map Accept media types to serializer names rather than exposing a query parameter:
application/json → DataArraySerializer (Dingo's typical default)application/vnd.api+json → JsonApiSerializertext/plain or a legacy client header → ArraySerializerThis lets older clients keep their data-unwrapped shape while new ones migrate to JSON:API without forking the API.
Query-string override for debugging and tests only. A ?serializer=jsonapi toggle is invaluable for QA and integration tests, but gate it behind app()->environment('local', 'testing') in your middleware wrapper, otherwise you've shipped an unauthenticated format switch to production.
Compose with Dingo versioning. Dingo's Accept-Version header already lives in the same header family. If you also do version negotiation, parse both in one place (a custom middleware wrapping this one) to avoid conflicting Accept parsing. Otherwise you'll get a v2 route returning a v1-shaped body because the serializer was chosen from the version string.
Testing pattern. Assert response shape per serializer, not just status codes:
$this->getJson('/users', ['Accept' => 'application/vnd.api+json'])
->assertJsonStructure(['data' => [['type', 'id', 'attributes']]]);
$this->getJson('/users')
->assertJsonStructure(['data' => [['id', 'name']]]);
Run the whole matrix in CI; serializer regressions are silent and only surface in client bugs.
Cache and ETag awareness. Serializer choice changes the byte-for-byte body, so any ETag computed over the response must include the serializer name. If you use api.etag, add the serializer to the cache key, or different clients with different Accept headers will get each other's cached payloads.
One middleware wrapper of your own. In practice you'll want a small local middleware that (a) resolves the target serializer, (b) calls app('Dingo\Api\Transformer\Adapter\Fractal')->setSerializer(new $class), and (c) restores the original in a finally. The package covers (b); a wrapper gives you (a), (c), and logging of which serializer each request chose — invaluable when a client says "the shape changed."
setSerializer() mutates shared state. In a classic FPM setup the app is torn down per request, but under Octane the worker is reused — you must reset the serializer after the response (in middleware's terminate() or a finally) or every subsequent request inherits the override. This is the single most important operational pitfall for this package.$this->response->item(...)), the serializer must already be set.errorFormat in config/api.php, not through the Fractal adapter. Swinging to JsonApiSerializer will not change error envelopes; clients expecting a JSON:API errors array will still receive {"message": "...", "status_code": 404}. If you need consistent error shapes, override errorFormat too, or normalize in a wrapper middleware.DataArraySerializer puts pagination under meta.pagination; JsonApiSerializer puts links under links and meta uses a different structure; ArraySerializer may drop pagination entirely from the top level. Clients that read meta.total will break under a different serializer. Document which serializers are supported per endpoint, and test paginated endpoints explicitly.JsonApiSerializer requires resource type keys. Fractal needs setResourceName() (or a $resourceKey) on every transformer, including nested included resources. Missing names give you {"type": null, ...} and unpredictable URLs in links. Audit all your transformers before enabling it as a public option.php artisan config:cache will freeze your serializer map. If you hot-edit the config in dev and wonder why the switch does nothing, clear it: php artisan config:clear. In CI, always run with a cleared cache when running serializer matrix tests.?include=posts behaves differently under ArraySerializer (no links, no type) vs JsonApiSerializer (compound documents). Test includes with each serializer you expose, not just top-level resources.ArraySerializer returns [] for empty, DataArraySerializer returns {"data": []}, NoopSerializer can return null. If a client does response.length, this is a breaking difference. Add a test for the empty case per serializer.?serializer=App\Evil\... must return 400, not instantiate.Log::debug('serializer', [
'chosen' => $name,
'accept' => $request->header('Accept'),
'fractal' => get_class(app('Dingo\Api\Transformer\Adapter\Fractal')->getSerializer()),
]);
Nine times out of ten a "wrong shape" bug is the middleware not being in the stack, or the Accept header being rewritten by a proxy/gateway in front of Laravel.setSerializer() API and use the package only for its serializer resolution helper. The underlying Dingo call is stable and public; the package is a convenience, not a hard dependency.How can I help you explore Laravel packages today?