everycheck/api-rest-utils
A Symfony utility package offering a JSON response builder with common API statuses and custom headers, plus a paginated repository trait for filtering, ordering, and searching entities across joins.
Know what you're actually installing. This is a small, archived Symfony helper library, not a framework-agnostic toolkit. The whole surface area is roughly two things: a ResponseBuilder that wraps jms_serializer and returns Symfony\Component\HttpFoundation\Response objects, and a Doctrine repository trait for filtered/ordered pagination. There are no dependents and one star, so treat it as reference code you may vendor rather than a dependency you trust blindly.
Install and inspect first.
composer require everycheck/api-rest-utils
Before writing any code, open vendor/everycheck/api-rest-utils/src/ and read:
ResponseBuilder.php — the constructor signature (the README shows new ResponseBuilder($this->get('jms_serializer'))), the exact status codes each method returns, and the JSON envelope shape ({"data": ...}, {"errors": ...}, or raw).composer.json — PHP version constraint, JMS serializer version, and autoload namespace. If it's not registered as a Symfony bundle, don't add it to bundles.php; if it is, the README omission is a docs gap.Register JMS serializer if you don't have it. The builder is useless without jms/serializer-bundle configured, since it delegates all serialization. That means at minimum a jms_serializer service and, realistically, metadata config for your entities.
First use case — the standard controller action. The canonical pattern from the README:
public function getAction($id)
{
$response = new ResponseBuilder($this->get('jms_serializer'));
$entity = $this->getDoctrine()->getManager()
->getRepository(Entity::class)->find($id);
if (empty($entity)) {
return $response->notFound();
}
return $response->ok($entity);
}
Start by migrating one read-only endpoint. Confirm the JSON shape and status codes match what your frontend already expects before touching write endpoints — that's where the created/deleted/conflict/formError semantics matter and where surprises live.
Inject the builder, don't rebuild it. The README constructs ResponseBuilder inside each action, which is noisy and hides the dependency. In Symfony, register it as a service and inject it into controllers:
# services.yaml
Everycheck\ApiRestUtils\ResponseBuilder:
arguments: ['@jms_serializer']
public: true
Then type-hint it in the action. This also gives you one place to subclass or decorate if you need an envelope change.
Early-return guard clauses with header chaining. addHeaders returns $this, so headers must be applied before the terminal call — ->addHeaders(...)->ok($data) works, ->ok($data)->addHeaders(...) silently does nothing useful.
public function show($id)
{
$entity = $this->repo->find($id);
if (!$entity) {
return $this->response->addHeaders('X-Request-Id', $rid)->notFound();
}
return $this->response
->addHeaders('X-Request-Id', $rid)
->addHeaders('Cache-Control', 'no-store')
->ok($entity);
}
Build a small base controller or trait that generates a request ID and applies standard headers so every endpoint is consistent.
Form errors as a first-class contract. formError is the one method with real logic in it — it flattens Symfony's FormInterface::getErrors() tree into a field-keyed JSON structure. Standardize on it across all mutating endpoints and let your client render field-level errors from one shape. Anything reaching for ->badRequest($form->getErrors()) raw is fighting the library's purpose.
Repository trait for list endpoints. The second half of the README covers a "find paginated with filter and order by" trait. The workflow:
// src/Repository/EntityRepository.php
use Everycheck\ApiRestUtils\Repository\PaginatedRepositoryTrait;
class EntityRepository extends EntityRepository
{
use PaginatedRepositoryTrait;
}
Then bind request query params (page, limit, sort, order, plus filter keys) to the trait's method in the controller. The value here is that filter/order handling is centralized instead of reimplemented per repository — which is exactly the kind of duplication that rots in API projects.
Compose a small response layer around it. Because ResponseBuilder only knows about serialization and status codes, the natural extension is a thin application-level service that:
ok() payloads via headers (X-Total-Count, Link) since the builder has no envelope for it,In a Laravel codebase, the direct analogue is replacing scattered response()->json([...], 404) calls with a single ApiResponder class exposing the same verb-based methods. You get the same day-to-day benefit — one status-code vocabulary across the team — without the Symfony coupling.
Archived and effectively unmaintained. No dependents, one star, README-only maturity. Do not expect bug fixes, security patches, or compatibility updates for new Symfony major versions. Pin the version and plan to vendor or reimplement. For a package this small, copying ResponseBuilder.php (a few hundred lines at most) into your app is often cheaper than carrying a dead dependency.
It is Symfony-only in practice. Everything is built on HttpFoundation + jms_serializer. In Laravel, symfony/http-foundation is already present transitively and jms/serializer works standalone, so you can use the response class — but you'd be bypassing Laravel's Response pipeline, middleware expectation of Illuminate\Http\Response, and JsonResource. In Laravel the honest play is to read the source and port the status-code mapping, not to install it.
empty is a legal method name only from PHP 7.0. The builder exposes ->empty(). It works on modern PHP, but linters, older static analyzers, and IDE autocomplete occasionally choke on it. Don't rename it in a vendored copy unless you're also updating callers.
notFound() returns a Response, not an exception. This means no exception listener, no unified error logging, no consistent trace for 404s. If you rely on Symfony's kernel.exception handling for logging/alerting, these responses will bypass it. Either accept that or wrap calls so failures funnel through an exception subscriber.
JMS serializer will bite on Doctrine relations. ->ok($entity) with a lazy-loaded bidirectional relation typically produces circular-reference exceptions or enormous payloads. Configure serialization groups / exclusion policies and, where the builder allows it, pass a context. Check whether the constructor or methods accept a $context argument — if not, you'll be configuring globally via JMS metadata, which is coarse.
forbiddenAcl vs forbiddenRoute vs forbidden is a leak surface. Distinguishing "you're not authenticated for this route" from "you lack ACL for this resource" is useful internally but can disclose resource existence to clients. Audit which one you call on public endpoints; consider collapsing to forbidden externally and logging the distinction.
Verify the pagination trait's filter handling before trusting it. A trait that accepts arbitrary query keys can either throw on unknown columns (500s) or, worse, interpolate them into DQL/SQL. Read the implementation, then wrap it: whitelist allowed filter fields and sort columns per repository, and cap limit (the trait likely won't). Also check the total-count query is a separate COUNT() rather than counting the hydrated page.
No tests means you own verification. There is no CI surface to lean on. Before rolling it out, write characterization tests asserting the exact status code and body shape of all fifteen response methods, plus one test per list endpoint covering filter/order/pagination bounds. That test file is the real documentation — the README omits method signatures and payload shapes entirely.
Header order and immutability. ResponseBuilder mutates itself and returns $this. If you hold one instance as an injected service and issue two responses in a single request (rare, but happens in error paths and sub-requests), headers leak between them. Register it as a non-shared service, or instantiate per action as the README does.
Extension points are thin but exist. Subclass ResponseBuilder to override the envelope or add methods (accepted, noContent if missing), and register the subclass as the service. Similarly, the repository trait can be overridden by defining the same method in the class that uses it — trait methods lose to class methods, so that's your escape hatch when a trait default doesn't fit.
How can I help you explore Laravel packages today?