webmunkeez / adr-bundle
Action-Domain-Responder pattern made for Symfony.
Package info
github.com/yannissgarra/adr-bundle
Type:symfony-bundle
pkg:composer/webmunkeez/adr-bundle
Requires
- php: >=8.2
- phpdocumentor/reflection-docblock: ^6.0
- symfony/config: ^7.4
- symfony/dependency-injection: ^7.4
- symfony/expression-language: ^7.4
- symfony/http-foundation: ^7.4
- symfony/http-kernel: ^7.4
- symfony/property-access: ^7.4
- symfony/property-info: ^7.4
- symfony/serializer: ^7.4
- twig/twig: ^3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3
- phpstan/phpstan: ^2
- phpstan/phpstan-deprecation-rules: ^2
- phpstan/phpstan-symfony: ^2
- phpunit/phpunit: ^11
- symfony/browser-kit: ^7.4
- symfony/css-selector: ^7.4
- symfony/framework-bundle: ^7.4
- symfony/phpunit-bridge: ^7.4
- symfony/twig-bundle: ^7.4
- symfony/uid: ^7.4
- symfony/yaml: ^7.4
This package is auto-updated.
Last update: 2026-08-19 13:25:41 UTC
README
This bundle unleashes the Action-Domain-Responder pattern on Symfony applications.
Installation
Use Composer to install this bundle:
$ composer require webmunkeez/adr-bundle
Add the bundle in your application kernel:
// config/bundles.php return [ // ... Webmunkeez\ADRBundle\WebmunkeezADRBundle::class => ['all' => true], // ... ];
Usage
Actions
An Action is just an invokable class that has to implement \Webmunkeez\ADRBundle\Action\ActionInterface:
final class StoryDetailAction implements \Webmunkeez\ADRBundle\Action\ActionInterface { public function __invoke(): Response { return $this->render($data); } public function render(?\Webmunkeez\ADRBundle\Response\ResponseDataInterface $data = null): Response { return new Response(...); } }
But, it can be a more classic Controller that implements the same interface:
final class StoryController implements \Webmunkeez\ADRBundle\Action\ActionInterface { public function detail(): Response { return $this->render($data); } public function render(?\Webmunkeez\ADRBundle\Response\ResponseDataInterface $data = null): Response { return new Response(...); } }
(Each service that implements ActionInterface is automatically tagged controller.service_arguments)
Responders
Responders are services which take data (an object that implements \Webmunkeez\ADRBundle\Response\ResponseDataInterface) and return it in a Response.
It can be a response containing HTML or a JsonResponse, or whatever you want, as far as it is a Symfony\Component\HttpFoundation\Response instance.
In this bundle, there is a responder manager \Webmunkeez\ADRBundle\Response\Responder that you can inject into your actions (or controllers).
This responder manager takes all responders of your application (it uses a compiler pass to get all services tagged webmunkeez_adr.responder sorted by priority) and find the right one to render the response.
final class StoryDetailAction implements \Webmunkeez\ADRBundle\Action\ActionInterface { public function __construct( private readonly \Webmunkeez\ADRBundle\Response\Responder $responder, ) { } public function __invoke(): Response { return $this->render($data); } public function render(?\Webmunkeez\ADRBundle\Response\ResponseDataInterface $data = null): Response { return $this->responder->render($data); } }
You can use \Webmunkeez\ADRBundle\Response\ResponderAwareInterface and \Webmunkeez\ADRBundle\Response\ResponderAwareTrait to automatically inject Responder:
final class StoryDetailAction implements \Webmunkeez\ADRBundle\Action\ActionInterface, \Webmunkeez\ADRBundle\Response\ResponderAwareInterface { use \Webmunkeez\ADRBundle\Response\ResponderAwareTrait; public function __invoke(): Response { return $this->render($data); } public function render(?\Webmunkeez\ADRBundle\Response\ResponseDataInterface $data = null): Response { return $this->responder->render($data); } }
Or, more simply, directly extend \Webmunkeez\ADRBundle\Action\AbstractAction, which already implements ActionInterface/ResponderAwareInterface and uses ResponderAwareTrait for you:
final class StoryDetailAction extends \Webmunkeez\ADRBundle\Action\AbstractAction { public function __invoke(): Response { return $this->render($data); } }
Responders are classes that implement \Webmunkeez\ADRBundle\Response\ResponderInterface (and so, they are automatically tagged webmunkeez_adr.responder):
final class XmlResponder implements \Webmunkeez\ADRBundle\Response\ResponderInterface { public function __construct( private readonly RequestStack $requestStack, private readonly SerializerInterface $serializer, ) { } public function supports(): bool { return 'xml' === $this->requestStack->getCurrentRequest()->getPreferredFormat(); } public function render(?\Webmunkeez\ADRBundle\Response\ResponseDataInterface $data = null): Response { $xml = $this->serializer->serialize($data, 'xml'); $response = new Response($xml); $response->headers->set('Content-Type', 'text/xml'); return $response; } }
As you can see, there are two methods: supports that defines conditions to "activate" the responder and render to make the response.
Core responders
There are two core responders provided:
HtmlResponder
\Webmunkeez\ADRBundle\Response\HtmlResponder that uses Twig for render html with a twig template. To indicate template, you have to use \Webmunkeez\ADRBundle\Attribute\Template:
#[\Webmunkeez\ADRBundle\Attribute\Template('story/detail.html.twig')] final class StoryDetailAction implements \Webmunkeez\ADRBundle\Action\ActionInterface { ... }
This responder is active if the request contains HTTP_ACCEPT text/html header (warning: a twig template is needed for this responder, otherwise it will throw an \Webmunkeez\ADRBundle\Exception\RenderingException exception).
It has a priority: -10.
JsonResponder
\Webmunkeez\ADRBundle\Response\JsonResponder that uses Serializer for render json (you can indicate serialization context with \Webmunkeez\ADRBundle\Attribute\SerializationContext):
#[\Webmunkeez\ADRBundle\Attribute\SerializationContext(['groups' => 'group_one'])] final class StoryDetailAction implements \Webmunkeez\ADRBundle\Action\ActionInterface { ... }
This responder is active if the request contains HTTP_ACCEPT application/json header.
It has a priority: -10.
Conditional attributes
\Webmunkeez\ADRBundle\Attribute\Template and \Webmunkeez\ADRBundle\Attribute\SerializationContext accept an optional second condition argument: an expression (evaluated with ExpressionLanguage) that has access to the current request.
They are also repeatable, so you can stack several of them on the same class/method. The first one whose condition is null or evaluates to true wins — so order matters: put your most specific conditions first, and a fallback one (no condition) last.
final class StoryDetailAction implements \Webmunkeez\ADRBundle\Action\ActionInterface { #[\Webmunkeez\ADRBundle\Attribute\Template('story/detail_fr.html.twig', condition: 'request.getLocale() === "fr"')] #[\Webmunkeez\ADRBundle\Attribute\Template('story/detail.html.twig')] public function __invoke(): Response { return $this->render($data); } }
In this example, French requests render detail_fr.html.twig, everything else falls back to detail.html.twig.
Custom responders
You can write your own reponders like in my previous XmlResponder example, by implementing \Webmunkeez\ADRBundle\Response\ResponderInterface.
Services implementing this interface are automatically tagged webmunkeez_adr.responder with priority: 0, and you can change it (in your service.yaml or by static getDefaultPriority method ; see https://symfony.com/doc/current/service_container/tags.html#tagged-services-with-priority).
You can define "generic" responders like html, json, xml and so on. But you can also define more specifics, by checking $request->attributes->get('_controller') to make a responder only for a specific action:
final class CustomResponder implements \Webmunkeez\ADRBundle\Response\ResponderInterface { public function __construct( private readonly RequestStack $requestStack, private readonly Environment $twig, ) { } public function supports(): bool { $controller = $this->requestStack->getCurrentRequest()->attributes->get('_controller'); $actionClass = false !== strpos($controller, '::') ? substr($controller, 0, strpos($controller, '::')) : $controller; return CustomResponderAction::class === $actionClass; } public function render(?\Webmunkeez\ADRBundle\Response\ResponseDataInterface $data = null): Response { $data = array_merge($data, ['customResponder' => true]); $html = $this->twig->render($this->requestStack->getCurrentRequest()->attributes->get('_template_path'), $data); return new Response($html); } }
Render Exception Listener
If there is an uncaught \Webmunkeez\ADRBundle\Exception\RenderingException, it will be catch by this listener which will throw an \Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException that will embed the original exception. If the cause is a missing #[Template] attribute (a developer mistake, not a client error), it is additionally logged as critical (with the route name and path) before being converted, since it would otherwise disappear as a silent 406 response.
Exception Listener
If there is an uncaught \Throwable that isn't already an \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface, it will be logged as critical (with the exception class, message, file and line) and caught by this listener, which will throw a 500 \Symfony\Component\HttpKernel\Exception\HttpException that will embed the original exception — an unhandled throwable is a server-side fault, not a client mistake, so it is never reported as a 4xx.
Http Exception Listener
If you request an Action with HTTP_ACCEPT application/json header and if this Action throws an Exception that implements \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface, its content will automatically be serialized in a JSON reading format to the Response body content. Both the message and code are always blanked in that JSON body (see HttpExceptionNormalizer) since either could otherwise leak internal/infrastructure details to the client; the real detail stays in your logs via the listeners above.
Security
This bundle performs no authentication or authorization of its own — it only dispatches to a Responder and formats exceptions into HTTP responses. Protecting your Actions (e.g. with Symfony Security voters/firewalls) is entirely your application's responsibility, the same as it would be for a regular controller.