tbbc/rest-util-bundle
TbbcRestUtilBundle is a Symfony bundle that streamlines building RESTful APIs. It offers base controller utilities, JSON response helpers, and consistent error handling, helping you write cleaner, more predictable API endpoints with less repetitive boilerplate code.
Install and register. The bundle is a thin Symfony wrapper around tbbc/rest-util, so the library itself comes in transitively:
composer require tbbc/rest-util-bundle
Register it the old way — this predates Flex recipes, so there is no config/bundles.php auto-entry:
// app/AppKernel.php (Symfony 2/3)
$bundles = [
// ...
new Tbbc\RestUtilBundle\TbbcRestUtilBundle(),
];
If you are on Symfony 4+, skip the bundle and pull tbbc/rest-util directly — the bundle's value is almost entirely service wiring, and that wiring is not autowiring-friendly.
Where to look first, in this order:
DependencyInjection/Configuration.php — the config tree. This tells you what formats are registered and the root key (typically tbbc_rest_util).Resources/config/services.xml — the real service IDs. Note them down; you will be injecting them by string ID, not by class.vendor/tbbc/rest-util/src/ — the actual decoders/serializers. The bundle is ~200 lines; all the real behaviour lives here.First use case: decoding a JSON/XML request body. This is the 90% case. Symfony's $request->request only populates for application/x-www-form-urlencoded and multipart/form-data; a PUT/PATCH/POST with Content-Type: application/json leaves you with an empty ParameterBag. The library gives you a decoder that inspects the Content-Type, picks a decoder, and returns a plain PHP array:
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class WidgetController extends Controller
{
public function createAction(Request $request)
{
$decoder = $this->get('tbbc_rest_util.request_decoder');
$payload = $decoder->decode($request); // array, or throws on bad/unknown format
// ... validate $payload, persist, etc.
}
}
That is the whole point of the package: a content-negotiating body decoder for JSON/XML, so controllers do not have to branch on getContent() themselves.
Inject the decoder, don't fetch it. In a modernised setup, alias the service to the library interface in config/services.yaml and let autowiring do the rest:
services:
Tbbc\RestUtil\RequestDecoder\RequestDecoderInterface:
alias: tbbc_rest_util.request_decoder
public: true
Then type-hint the interface in your controller action or, better, in a command handler / form-data DTO factory. Keeping the decoder out of controllers is the single biggest maintainability win here — controllers end up as decode → validate → dispatch and nothing else.
Wrap it in a single "request payload" service. You almost never want raw decode errors surfacing as 500s. A small adapter gives you one place to normalise failures:
final class PayloadReader
{
public function __construct(private RequestDecoderInterface $decoder) {}
public function read(Request $request): array
{
try {
$data = $this->decoder->decode($request);
} catch (UnsupportedFormatException $e) {
throw new HttpException(415, 'Unsupported Media Type');
} catch (InvalidFormatException $e) {
throw new HttpException(400, 'Malformed request body');
}
return is_array($data) ? $data : [];
}
}
Register a kernel.exception listener or use HttpException + Symfony's built-in handling, and your API is RFC-correct on 400/415 for free.
Handle the response side separately. The package is asymmetric: it is strongest at decoding requests. For responses, keep using JsonResponse for JSON and only reach for the library's serializers when a client genuinely needs XML:
// content negotiation done once, in a listener or a small factory
$response = new Response($serializer->serialize($data, 'xml'));
$response->headers->set('Content-Type', 'application/xml');
Trying to force one abstraction over both directions usually ends in a View layer that duplicates FOSRestBundle. Don't.
Coexist with FOSRestBundle rather than fight it. If you already use FOSRestBundle, it has its own body listeners (fos_rest.body_listener) that populate $request->request from JSON. Pick one. A pragmatic split: FOSRestBundle for routing/format negotiation/error rendering, and this decoder only if you need XML request bodies, which FOSRestBundle handles less gracefully.
Use it in tests directly. Because the decoder is a pure-ish service, you can unit-test payload handling without an HTTP round trip:
$request = Request::create('/widgets', 'POST', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => 'Acme']));
self::assertSame(['name' => 'Acme'], $decoder->decode($request));
For functional tests, remember Request::create's sixth argument (server) is where CONTENT_TYPE must go — setting it as a header on the client call works too, but the raw $content argument matters:
$client->request('POST', '/widgets', [], [], ['CONTENT_TYPE' => 'application/json'], '{"name":"Acme"}');
Custom formats. The extension point is a custom decoder implementing the library's decoder interface, registered against a format key. This is how you'd add, say, application/vnd.acme+json:
services:
app.decoder.vnd_json:
class: App\Rest\VndJsonDecoder
tags: [{ name: tbbc_rest_util.decoder, format: vnd.acme+json }]
Verify the tag name against DependencyInjection/ — if no tag is compiled, you fall back to overriding the decoder collection service definition directly.
The bundle is effectively unmaintained. 30 stars, zero dependents, no recipe, and service definitions written for Symfony 2. Expect to fight public: false, missing autowire, and Symfony\Component\HttpFoundation\Request::getContent() semantics that changed across versions. Budget an hour for wiring, or vendor the 3–4 classes you actually need.
Content-Type is mandatory and exact. A POST with a body but no Content-Type typically raises "unsupported format", not "empty body". application/json; charset=utf-8 must be parsed down to the media type before lookup — check whether the decoder strips parameters; several versions do not, and you get a 415 for a perfectly valid request. This is the #1 source of "it works in Postman, fails in curl" reports.
php://input is read-once on some SAPIs. Use $request->getContent() (which Symfony caches) rather than reading the stream yourself anywhere upstream, or the decoder sees an empty string.
Empty body handling is undefined-ish. {"": ""} vs "" vs null may decode to null, [], or throw depending on version. Guard explicitly: if ('' === trim($request->getContent())) { /* 400 or 204, your call */ }.
XML array shapes will surprise your consumers. SimpleXML→array conversion flattens repeated elements inconsistently, drops attributes unless configured, and leaves whitespace text nodes. If you accept XML, write a fixture-based test asserting the exact array shape for a 1-element list, an N-element list, and an element with attributes. Also, this is a hand-rolled parser: disable external entity loading (libxml_disable_entity_loader(true) or LIBXML_NONET) before you expose it to the internet — XXE is a real risk with naive SimpleXML decoding.
JSON edge cases. Large integers become floats, empty objects become empty arrays, and json_decode depth limits silently produce null with no exception unless you check json_last_error(). If your API has IDs above 2^53 or meaningful {} vs [] distinctions, decode with JSON_OBJECT_AS_ARRAY off or post-process — do not assume the library does this for you.
Do not use it for multipart/form-data or file uploads. Decoders read the raw body; uploaded files live in $request->files and are handled by PHP before Symfony sees them. Route those actions through $request->request / $request->files as normal.
Debugging checklist, in order: bin/console debug:container tbbc_rest_util to confirm the service exists and is public; debug:container --tag=... to confirm your custom decoder was tagged; log $request->headers->get('Content-Type') and strlen($request->getContent()) at the top of the failing action — 95% of failures are one of those two being wrong.
Never log the decoded payload wholesale. It is the full request body and routinely contains credentials or PII. Log the format and the byte length instead.
Config quirks. The format map is usually a flat list under the bundle's root key; adding a format there without a corresponding decoder service produces a container-time or runtime error depending on version. Change config and decoder registration in the same commit.
Migration tip. If you are modernising, the cleanest exit is: replace $decoder->decode($request) with a small symfony/serializer-based decoder in your own PayloadReader, and drop the dependency entirely. You lose XML parsing convenience and gain autowiring, maintained code, and a real error model — a good trade for a 30-star bundle.
How can I help you explore Laravel packages today?