weblabel/api-bundle
WebLabel API Bundle is a Composer-installable Symfony bundle offering a ready-to-use API layer for WebLabel applications, with routing, serialization, configurable endpoints, and consistent integration patterns that reduce boilerplate and speed delivery.
weblabel/api-bundle is a small Symfony bundle (not Laravel) that packages the plumbing most REST APIs need and nobody wants to rewrite: a base API controller, JSON response helpers, an exception listener that turns thrown exceptions into consistent JSON error payloads, and helpers around request-body validation. There is essentially no documentation, so treat the source as the spec.
Minimal setup
composer require weblabel/api-bundle
Register it (Symfony Flex may do this; verify):
// config/bundles.php
return [
// ...
Weblabel\ApiBundle\WeblabelApiBundle::class => ['all' => true],
];
Check vendor/weblabel/api-bundle/DependencyInjection/Configuration.php for the config root key (usually weblabel_api) and any defaults. In most setups you can run with zero config.
Where to look first, in this order:
Controller/ApiController.php — the class you will extend. Whatever helpers it exposes (respond, respondCreated, respondNoContent, respondError-style methods) become your team's vocabulary.EventListener/ — the exception listener. This is the highest-value piece: it decides which exception classes map to which HTTP status and what the JSON error envelope looks like.Exception/ — the typed exceptions you're expected to throw (NotFoundException, BadRequestException, ValidationException, etc.).README.md — expect one paragraph; don't budget time for it.First use case — make /api/health return JSON and make a missing entity return 404 application/json instead of Symfony's HTML error page:
namespace App\Controller;
use Weblabel\ApiBundle\Controller\ApiController;
use Weblabel\ApiBundle\Exception\NotFoundException;
use Symfony\Component\Routing\Annotation\Route;
class OrderController extends ApiController
{
/** @Route("/api/orders/{id}", methods={"GET"}) */
public function show(int $id)
{
$order = $this->getDoctrine()->getRepository(Order::class)->find($id);
if (!$order) {
throw new NotFoundException("Order {$id} not found");
}
return $this->respond($order, ['groups' => ['order:read']]);
}
}
If respond() doesn't exist under that name, the base controller almost certainly exposes an equivalent — confirm the signature before writing more than one controller against it.
One resource controller per aggregate, all extending the base controller. This keeps the response envelope and error behaviour uniform. Don't mix in plain AbstractController responses for "just this one endpoint" — clients will see two error shapes.
Throw, don't return errors. The listener pattern only pays off if you commit to it:
if (!$user->isActive()) {
throw new BadRequestException('Account is inactive');
}
Map your domain exceptions to status codes in one place (typically a getStatusCode() on each exception class, or a match table in the listener). When you add a new exception type, add it to that table and add a test asserting the JSON body.
JSON request bodies. Symfony does not populate $request->request for application/json. Either use the bundle's body-decoding helper if it ships one, or do it explicitly and validate with the Symfony Validator:
$payload = json_decode($request->getContent(), true) ?? [];
$dto = $this->denormalize($payload, CreateOrderInput::class);
$errors = $this->validator->validate($dto);
if (count($errors) > 0) {
throw new ValidationException($errors); // → 422 with field-level details
}
Keep DTOs separate from entities. Denormalising straight into Doctrine entities is where most of the security and validation bugs come from.
Serialization groups over hand-built arrays. Use ['groups' => ['order:read']] on the response helper and annotate entities/DTOs. This is the cheapest way to stop leaking password hashes and internal flags, and it gives you a free read/write split.
Pagination envelope. Pick one shape and never deviate:
{ "data": [...], "meta": { "page": 2, "per_page": 25, "total": 1043 } }
If the bundle provides a paginator helper, use it; otherwise a small PaginatedResponse value object keeps controllers to three lines.
Integration tips
| Laravel habit | Symfony equivalent here |
|---|---|
return response()->json($x, 201) |
base controller respond($x, 201) |
abort(404) / ModelNotFoundException |
throw new NotFoundException() |
$request->validate([...]) |
Validator + ValidationException |
| API Resources | Serializer groups |
Handler::render in app/Exceptions/Handler.php |
kernel.exception listener from this bundle |
Testing. Use KernelBrowser / WebTestCase and assert on status + JSON structure, not on exact strings. One test per exception mapping is worth more than ten happy-path tests, because the listener is the piece most likely to silently regress.
This is a 0-star, 0-dependent, single-release package (last tag 2021-12-08). Pin the exact version in composer.json, and consider copying the handful of classes you actually use into src/Api/ rather than carrying a dependency that will not receive Symfony 7 compatibility. The bundle is small enough that vendoring is realistic; the exception listener is ~100 lines.
JSON bodies are empty in $request->request. Repeating this because it is the #1 bug: use $request->getContent(), and guard against malformed JSON (json_decode returns null for both "null" and invalid input — use JSON_THROW_ON_ERROR or check json_last_error()).
The exception listener does not catch everything. Symfony's security firewall short-circuits before your controller, so 401/403 will be rendered by the firewall entry point, often as HTML or a redirect. Configure a custom AuthenticationEntryPoint / AccessDeniedHandler that emits the same JSON envelope. Same story for OPTIONS preflight requests if you handle CORS at the web-server or listener level.
HttpException subclasses may bypass your listener. If you throw NotFoundHttpException, whether you get JSON or HTML depends on listener priority and kernel.debug. Register/inspect with bin/console debug:event-dispatcher kernel.exception and, if needed, decorate or re-prioritise. Keep your own exception types as the primary contract.
Circular references in serialization produce a 500 that looks like a listener bug. Fix with circular_reference_handler in framework.serializer config, and always specify groups on respond().
Debug the error path with APP_DEBUG=0. In dev, Symfony's error page can mask what the listener actually produces. Test the production shape before shipping.
Annotation → attribute migration. The 2021 release predates Symfony 6.2 attribute routing. If you're on 6.2+, your own controllers can use #[Route] regardless, but any annotation-driven config inside the bundle needs sensio/framework-extra-bundle compatibility checking.
Extension points are: (a) overriding the base controller, (b) decorating the exception listener with your own service that adds logging/request-id propagation and delegates, and (c) tagging your own normalizers. Prefer (b) over forking — it survives a vendor update if one ever comes.
Before adopting for a new project, compare against a maintained alternative (API Platform for full CRUD, or ~50 lines of your own kernel.exception listener + base controller). The bundle's value is the pattern, not the code; the pattern is easy to reproduce and easier to own.
How can I help you explore Laravel packages today?