tbbc/rest-util
tbbc/rest-util is a lightweight Laravel utility package for building RESTful APIs, offering helpers for consistent JSON responses, error handling, and request/response formatting to reduce controller boilerplate.
tbbc/rest-util is a small Symfony bundle that gives you three things FOSRestBundle users often rebuild by hand:
View value object — controllers return new View($data, $status, $format) instead of constructing a Response, and a kernel.view listener serializes it for you.kernel.request listener parses JSON/XML bodies based on Content-Type and exposes the decoded array as a request attribute.Error payload rendered in the same format as the request, with configurable exception→HTTP status mappings.Minimum steps:
composer require tbbc/rest-util
// app/AppKernel.php (Symfony 2/3 era)
new Tbbc\RestUtilBundle\TbbcRestUtilBundle(),
# config.yml
tbbc_rest_util:
view:
formats:
json: application/json
xml: application/xml
First use case — make one endpoint return a View and confirm it serializes:
use Tbbc\RestUtil\View\View;
class PingController
{
public function pingAction()
{
return new View(['pong' => true, 'ts' => time()], 200, 'json');
}
}
Hit the route, then break it on purpose (return an invalid format, throw an exception) to see how the listener and error handler react. Where to look first:
Resources/config/ (services.xml/*.yml) — the definitive list of services, listener priorities, and the request attribute key used for the decoded body.View/ViewListener.php — how format is resolved, how the serializer is invoked, how headers/status are applied.Error/ (ErrorFactory, ErrorResolver, resolvers) — the exception→status map and payload shape.This is an unmaintained Symfony 2.x/3.x-era bundle (16 stars, no recent activity). Treat the first hour as a compatibility audit against your Symfony version before planning anything on it.
Keep controllers thin: fetch data, return a View, let the listener serialize.
public function showAction($id)
{
$book = $this->bookRepository->find($id);
if (!$book) {
throw new NotFoundHttpException(sprintf('Book %s not found', $id));
}
return new View($book, 200, 'json', [
'X-Resource-Id' => $book->getId(),
'Cache-Control' => 'private, max-age=60',
]);
}
This makes controllers unit-testable without HTTP concerns — assert on $view->getData(), getStatusCode(), getFormat(). It also centralizes serialization (JMS Serializer by default), so serialization groups and versioning live in one place instead of per-controller.
Resolve the response format once (Accept header / _format / extension) and stash it on the request, then have controllers default to it:
$format = $request->attributes->get('_format', 'json');
return new View($data, 200, $format);
Keep the negotiation logic in a small subscriber or a base controller — don't scatter format checks through actions.
For POST/PUT/PATCH with Content-Type: application/json, the request listener decodes the body so you can validate it as PHP arrays rather than json_decode($request->getContent(), true) in every action. Read the decoded payload from the request attribute documented in services.xml, then hand it to a Symfony Form or a validator:
public function createAction(Request $request)
{
$payload = $request->attributes->get(/* key from services.xml */);
$form = $this->createForm(BookType::class, null, ['csrf_protection' => false]);
$form->submit($payload);
if (!$form->isValid()) {
throw new BadRequestHttpException((string) $form->getErrors(true));
}
// ...
}
The pattern that pays off: one decode path, one validation path, one error path. Never mix $request->request->all() and the decoded attribute in the same codebase.
Let domain/HTTP exceptions bubble to the error listener instead of hand-rolling JSON error responses. Map your exception codes to statuses in config, then throw plain exceptions from services:
tbbc_rest_util:
error:
resolver:
exception:
map:
4040: 404 # BookNotFound
4090: 409 # DuplicateIsbn
This keeps services framework-agnostic (they throw domain exceptions) while the bundle produces a consistent envelope:
{ "code": 4090, "message": "Duplicate ISBN", "details": { "isbn": "..." } }
For errors that aren't exceptions (validation aggregates, partial failures), build them explicitly with ErrorFactory so the shape never drifts from exception-rendered errors.
application/vnd.api+json, CSV, or form-encoded bodies.DomainException maps to 422 with field-level details, without touching core files.Both are the sanctioned extension points — prefer them over decorating listeners.
If you already run FOSRestBundle, you have overlapping kernel.view listeners. Pick one serializer path: either let rest-util's ViewListener handle View returns and let FOSRestBundle handle everything else, or drop one of them. Mixing produces "no listener supports the controller result" or double-serialization bugs that are painful to trace.
kernel.view and kernel.exception run in priority order. If Twig's exception listener, FOSRestBundle's, or a profiler listener runs before rest-util's, you'll get HTML error pages or null responses instead of your JSON envelope. Dump $event->getRequest()->attributes and the listener order (debug:event-dispatcher) when responses look wrong.View must be the controller's return value. If you return a Response and expect the listener to post-process it, it won't. The listener only fires when nothing else supported the result, and only for the View type it's configured for.View with 'format' => 'yaml' when only json/xml are configured typically fails at serialization time — usually as a 500, not a 406. Validate the negotiated format against your allow-list early.services.xml and the request listener. If your controllers and any custom listeners disagree on the key, you'll silently get null on writes — the classic symptom is "POST works in tests, null in production."debug mode, profiler, and error logs rather than assuming the bundle is broken.Doctrine\DBAL exception message in a production 500 is a real vulnerability. Map generic exceptions to a safe message in the resolver and log the original.View or Error typically defaults to 200; a validation error returned as 200 will break every well-behaved API client. Make the status explicit and lint it in code review.View are merged, not replaced, onto the final Response — if you also set Content-Type manually you can end up with duplicates or a mismatch between the negotiated format and the header.ViewListener, decoders, and resolvers by hand (or via services.xml overrides) if you're on a modern Symfony skeleton; the bundle predates autoconfiguration.View and Error are plain value objects, unit-test controllers and error resolvers without booting the kernel. Reserve WebTestCase for the two integration seams: format negotiation and exception→response rendering.How can I help you explore Laravel packages today?