flying-narwhal/json-request-bundle
Symfony bundle that decodes JSON request bodies into Symfony's request parameter bag, letting controllers read JSON payloads through the usual $request->request API without custom parsing.
This is a small Symfony bundle whose job is narrow: detect JSON requests and populate Symfony's $request->request parameter bag with the decoded payload, so controllers can use $request->request->get('field') instead of calling json_decode($request->getContent(), true) by hand. It effectively backfills $_POST for application/json bodies (including PUT/PATCH/DELETE, where PHP never populates $_POST).
Minimal steps:
composer require flying-narwhal/json-request-bundleconfig/bundles.php (FlyingNarwhal\JsonRequestBundle\JsonRequestBundle::class => ['all' => true]) if Flex doesn't do it for you.composer.json in the vendor directory first — confirm the symfony/http-kernel constraint matches your Symfony version before going further. With 6 stars and 0 dependents, version compatibility is the most likely first blocker.Where to look first, in order:
README.md — the usage contract, if it's kept current.src/EventListener/ (or src/RequestTransformer/) — the kernel.request listener that does the decoding. This is ~15–40 lines and is the entire behavior of the package; read it.src/DependencyInjection/Configuration.php — the exact config keys (global on/off, content-type matching, error behavior). Don't guess these from the README.composer.json — PHP version, Symfony constraints, whether tests exist.First use case: a POST /api/orders endpoint accepting a JSON body. Before the bundle you'd write $data = json_decode($request->getContent(), true) ?? throw new BadRequestHttpException(); at the top of every action. After, the controller reads $request->request->get('customer_id') and stays in Symfony idiom (including $request->request->all() for mass-assignment-style hydration into a DTO).
Before adopting, note that Symfony 6.3+ ships #[MapRequestPayload] and the RequestPayloadValueResolver natively. If you're on a modern Symfony, this bundle's value is mostly historical. On 5.4/6.0–6.2 it fills a real gap cheaply.
Global vs. route-scoped. Most such bundles apply to any request whose Content-Type matches. Confirm whether the listener checks the method, the content type, or both; if it's global, decide deliberately whether you want JSON decoding on HTML form routes too (usually harmless, occasionally surprising). If the bundle exposes an enabled/match config, prefer narrowing it over maintaining a fork.
Controller reads from the request bag, not getContent(). Pick one convention per codebase and stick to it. Mixing $request->request->get() and $request->getContent() in the same project is the fastest way to get inconsistent behavior when someone later disables the bundle or adds a route without it.
public function create(Request $request, ValidatorInterface $validator): Response
{
$dto = new CreateOrderDto();
$dto->customerId = $request->request->get('customer_id');
// ... hydrate remaining fields
$errors = $validator->validate($dto);
if (count($errors) > 0) {
return $this->json(['errors' => (string) $errors], 422);
}
// ...
}
Hydration via serializer. A clean pattern is to let the bundle get you as far as $request->request->all(), then hand that array to Serializer::denormalize($payload, CreateOrderDto::class, 'json'). This keeps validation and type coercion in one place and is a one-line swap to #[MapRequestPayload] later.
Error handling. Decide early what a malformed body returns. If the listener throws BadRequestHttpException, Symfony renders 400 automatically — good. If it swallows errors and leaves the bag empty, you'll get confusing 422s downstream instead of a clear 400. Add a kernel.exception listener or a problem+json response format so API clients get a consistent shape.
Symfony Forms and ParamConverter. The bundle only fills $request->request; forms still need to be submitted with $form->submit($request->request->all(), false) — and note the clearMissing = false flag, since with JSON you typically want PATCH semantics where absent keys don't null out fields. ParamConverter/EntityValueResolver route-parameter resolution is unaffected.
Subrequests and ESI. The listener fires on kernel.request for subrequests too. If your JSON endpoints are ever invoked as fragments, you can get double-decoding or unexpected 400s. Gate on $request->isMainRequest() in a custom listener if this bites.
Testing. Exercise it the way real clients do — through the kernel, with the header set:
$client->request(
'POST',
'/api/orders',
[], [], ['CONTENT_TYPE' => 'application/json'],
json_encode(['customer_id' => 42])
);
Never call the action directly with Request::create() and a hand-built bag; that bypasses the listener and tests nothing about the bundle.
Integration with json_login. The security firewall reads the body itself via the same content-type check. Both can coexist, but if you hit "invalid credentials" with an obviously correct payload, check listener priority — the firewall's authenticator must still see the raw stream or a fully populated bag, depending on version.
text/plain, application/json;charset=UTF-8 (usually fine), or application/vnd.api+json. Check the listener's matcher; vendor +json suffixes are frequently missed. Curl defaults to application/x-www-form-urlencoded, which silently skips the bundle.json_decode('') returns null, not an array. If the listener calls $request->request->replace(null) you'll get a TypeError; if it replaces with [] you'll get a valid-looking empty request. Test Content-Length: 0 explicitly.[1,2,3] decodes to a list, which is not what ParameterBag wants. The bundle may throw or may mangle it. If your API accepts arrays, don't rely on the parameter bag — read getContent() directly for those endpoints.replace() with the decoded array preserves ints and booleans, so $request->request->get('qty') can return an int — but $request->query->get() never will. Don't write code that depends on string-ness of JSON-sourced params.php://input (rare, but a custom authenticator or a body-signing listener can), you get an empty payload. Use bin/console debug:event-dispatcher kernel.request to see the actual order and adjust priority via a compiler pass or service override.json_decode will just eat memory. Rely on client_max_body_size (nginx) or post_max_size at the edge — Symfony's Request::getContent() isn't bounded either.request bag contents — the fastest way to confirm whether the listener fired. If it's empty and you're sure the content type is right, the listener likely isn't registered: bin/console debug:container --tag=kernel.event_listener.services.yaml is more robust than forking.#[MapRequestPayload] makes the bundle redundant anyway. Only reach for it if you're pinned to an older Symfony and want the convenience for free.$request->request->all() a tempting wholesale DTO hydrator. Bind only known fields; JSON clients can send arbitrary keys, and an unfiltered denormalize into an entity is an exploit waiting to happen.How can I help you explore Laravel packages today?