pitch/symfony-adr
Symfony ADR bundle implementing the Action-Domain-Responder pattern: dedicated action classes, domain logic, and responders for clean, testable request handling outside the traditional MVC flow.
composer require phfritsche/symfony-adr
HttpKernel (bundles, route defaults, view/response listeners) and has no service provider or laravel/framework constraint. Install it in a Symfony app, or use it as a design reference in Laravel (see below). Do not add it to a Laravel composer.json expecting responses to change.Phfritsche\SymfonyAdr\SymfonyAdrBundle in config/bundles.php (['all' => true]) unless a Flex recipe already did it.^1.4. Pin "phfritsche/symfony-adr": "^1.4" on Symfony 6.Responder contract shipped with the bundle, the _responder route default, and the payload type (the package provides a marker interface if you want one; otherwise the payload is just whatever your Action returns).Response and split it. The Action keeps domain/service calls and returns a payload; the Responder turns that payload into the Response:final class ShowPost
{
public function __construct(private PostRepository $posts) {}
public function __invoke(int $id): array // Action: no HTTP types
{
return ['post' => $this->posts->findOrFail($id)];
}
}
final class ShowPostResponder implements Responder
{
public function __construct(private Environment $twig) {}
public function __invoke(array $payload): Response
{
return new Response($this->twig->render('post/show.html.twig', $payload));
}
}
#[Route('/posts/{id}', defaults: ['_responder' => ShowPostResponder::class])]
Wire the responder via the route default (_responder), not inside the Action.
_responder values. This is the cleanest lever the bundle gives you; use it instead of Accept header branching inside controllers.RouterInterface, Environment, SerializerInterface, logger, etc. Register them as services (autowiring + autoconfigure) so the route default can resolve them by class name.Response directly from an Action bypasses responder resolution. Convenient for health checks/redirects, but it means two code paths in one controller set — document when you use it.adr.event_dispatcher in the adr config namespace), you get hooks around action/responder execution for logging, timing, or instrumentation without touching either class. Leave it off unless you need it — it adds a listener to every ADR request.assertSame/DTO equality). Responder: instantiate with a real Twig/mocked serializer, assert status, headers, and body. No KernelBrowser needed for either — reserve functional tests for routing/_responder wiring.Responder service (resolved via container or a route default you read in the controller) converts it to Illuminate\Http\Response. You keep the testability win and lose nothing, since Laravel has no equivalent of the kernel.view hook the bundle relies on.^1.4 is fine on 4.4/5.x, so one constraint works across a mixed estate._responder class isn't a resolvable service (private/non-autowired/typo), you get a failure when the route is hit, not at cache warmup. Run bin/console debug:router <name> and confirm _responder appears in the defaults, then debug:container <Responder> to confirm it's public or reachable._responder = silent passthrough. A route without a responder default isn't an error in the usual "your Action returned a payload and nothing rendered" sense — the payload just never reaches a responder. If a page renders empty, check the route default before debugging the template.kernel.view ordering. The bundle works by intercepting the Action's return value before a Response exists. Other kernel.view listeners (or anything expecting a Response from the controller) now see a payload instead. Audit existing listeners when retrofitting an app.kernel.response listeners still run after your Responder returns, so security headers, Vary, and debug toolbars behave normally — but kernel.controller-based logic that assumed AbstractController (e.g. $this->denyAccessUnlessGranted) doesn't exist on a plain Action. Inject AuthorizationCheckerInterface or use attributes/voters.dump() the payload at the top of the Responder; if it never fires, the problem is routing/_responder, not your domain code. bin/console debug:event-dispatcher kernel.view shows whether the ADR listener is registered.HttpFoundation/ArgumentResolver signatures that custom responders may have relied on loosely.How can I help you explore Laravel packages today?