[HttpKernel] Expose controller metadata throughout the request lifecycle

This commit is contained in:
Nicolas Grekas
2026-01-14 16:19:10 +01:00
parent 44f9ba8939
commit fe282b47ca
11 changed files with 240 additions and 22 deletions

View File

@@ -7,7 +7,7 @@ CHANGELOG
* Add support for `UploadedFile` when using `MapRequestPayload`
* Add support for bundles as compiler pass
* Add support for `SOURCE_DATE_EPOCH` environment variable
* Add property `$controllerArgumentsEvent` to `ResponseEvent`
* Add property `$controllerMetadata` to several kernel events to give listeners access to controller metadata
* Add `Request` attribute `_controller_attributes` to decouple controller attributes from their source code
* Return attributes as a flat list when using `Controller[Arguments]Event::getAttributes('*')`
* Pass `request` and `args` variables to `Cache` attribute expressions containing the `Request` object and controller arguments
@@ -16,6 +16,7 @@ CHANGELOG
* Deprecate passing a non-flat list of attributes to `Controller::setController()`
* Deprecate the `Symfony\Component\HttpKernel\DependencyInjection\Extension` class, use the parent `Symfony\Component\DependencyInjection\Extension\Extension` class instead
* Allow using Expression or \Closure for `validationGroups` in `#[MapRequestPayload]` and `#[MapQueryString]`
* Deprecate passing a `ControllerArgumentsEvent` to the `ViewEvent` constructor; pass a `ControllerArgumentsMetadata` instead
8.0
---

View File

@@ -61,17 +61,26 @@ final class ControllerArgumentsEvent extends KernelEvent
unset($this->namedArguments);
}
/**
* @return list<mixed>
*/
public function getArguments(): array
{
return $this->arguments;
}
/**
* @param list<mixed> $arguments
*/
public function setArguments(array $arguments): void
{
$this->arguments = $arguments;
unset($this->namedArguments);
}
/**
* @return array<string, mixed>
*/
public function getNamedArguments(): array
{
if (isset($this->namedArguments)) {

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\Event;
/**
* Provides read-only access to controller metadata.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class ControllerArgumentsMetadata extends ControllerMetadata
{
public function __construct(
ControllerEvent $controllerEvent,
private ControllerArgumentsEvent $controllerArgumentsEvent,
) {
parent::__construct($controllerEvent);
}
/**
* @return list<mixed>
*/
public function getArguments(): array
{
return $this->controllerArgumentsEvent->getArguments();
}
/**
* @return array<string, mixed>
*/
public function getNamedArguments(): array
{
return $this->controllerArgumentsEvent->getNamedArguments();
}
}

View File

@@ -21,8 +21,6 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
* setController() you can set a new controller that is used in the processing
* of the request.
*
* Controllers should be callables.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
final class ControllerEvent extends KernelEvent

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\Event;
/**
* Provides read-only access to controller metadata.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class ControllerMetadata
{
public function __construct(
private ControllerEvent $controllerEvent,
) {
}
public function getController(): callable
{
return $this->controllerEvent->getController();
}
public function getReflector(): \ReflectionFunctionAbstract
{
return $this->controllerEvent->getControllerReflector();
}
/**
* @template T of object
*
* @param class-string<T>|'*'|null $className
*
* @return ($className is null ? array<class-string, list<object>> : ($className is '*' ? list<object> : list<T>))
*/
public function getAttributes(?string $className = null): array
{
return $this->controllerEvent->getAttributes($className);
}
}

View File

@@ -38,6 +38,7 @@ final class ExceptionEvent extends RequestEvent
int $requestType,
\Throwable $e,
private bool $isKernelTerminating = false,
public readonly ?ControllerMetadata $controllerMetadata = null,
) {
parent::__construct($kernel, $request, $requestType);

View File

@@ -11,6 +11,9 @@
namespace Symfony\Component\HttpKernel\Event;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* Triggered whenever a request is fully processed.
*
@@ -18,4 +21,12 @@ namespace Symfony\Component\HttpKernel\Event;
*/
final class FinishRequestEvent extends KernelEvent
{
public function __construct(
HttpKernelInterface $kernel,
Request $request,
?int $requestType,
public readonly ?ControllerMetadata $controllerMetadata = null,
) {
parent::__construct($kernel, $request, $requestType);
}
}

View File

@@ -31,7 +31,7 @@ final class ResponseEvent extends KernelEvent
Request $request,
int $requestType,
private Response $response,
public readonly ?ControllerArgumentsEvent $controllerArgumentsEvent = null,
public readonly ?ControllerArgumentsMetadata $controllerMetadata = null,
) {
parent::__construct($kernel, $request, $requestType);
}

View File

@@ -25,13 +25,38 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
*/
final class ViewEvent extends RequestEvent
{
public readonly ?ControllerArgumentsMetadata $controllerMetadata;
/**
* @deprecated since Symfony 8.1, use $controllerMetadata instead
*/
public private(set) ?ControllerArgumentsEvent $controllerArgumentsEvent {
get {
trigger_deprecation('symfony/http-kernel', '8.1', 'Accessing the "controllerArgumentsEvent" property of the "%s" class is deprecated. Use "controllerMetadata" instead.', __CLASS__);
if (!$m = $this->controllerMetadata) {
return null;
}
return $this->controllerArgumentsEvent ??= new ControllerArgumentsEvent($this->getKernel(), \Closure::bind(fn () => $this->controllerEvent, $m, ControllerMetadata::class)(), $m->getArguments(), $this->getRequest(), $this->getRequestType());
}
}
public function __construct(
HttpKernelInterface $kernel,
Request $request,
int $requestType,
private mixed $controllerResult,
public readonly ?ControllerArgumentsEvent $controllerArgumentsEvent = null,
ControllerArgumentsMetadata|ControllerArgumentsEvent|null $controllerMetadata = null,
) {
if ($controllerMetadata instanceof ControllerArgumentsEvent) {
trigger_deprecation('symfony/http-kernel', '8.1', 'Passing a ControllerArgumentsEvent to the ViewEvent constructor is deprecated. Pass a ControllerArgumentsMetadata instance instead.');
$this->controllerArgumentsEvent = $controllerMetadata;
$controllerEvent = \Closure::bind(fn () => $this->controllerEvent, $controllerMetadata, ControllerArgumentsEvent::class)();
$controllerMetadata = new ControllerArgumentsMetadata($controllerEvent, $controllerMetadata);
}
$this->controllerMetadata = $controllerMetadata;
parent::__construct($kernel, $request, $requestType);
}

View File

@@ -20,7 +20,9 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsMetadata;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ControllerMetadata;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -35,6 +37,7 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
// Help opcache.preload discover always-needed symbols
class_exists(ControllerArgumentsEvent::class);
class_exists(ControllerArgumentsMetadata::class);
class_exists(ControllerEvent::class);
class_exists(ExceptionEvent::class);
class_exists(FinishRequestEvent::class);
@@ -73,7 +76,7 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
$this->requestStack->push($request);
$response = null;
try {
return $response = $this->handleRaw($request, $type);
return $response = $this->handleRaw($request, $type, $controllerMetadata);
} catch (\Throwable $e) {
if ($e instanceof \Error && !$this->handleAllThrowables) {
throw $e;
@@ -83,12 +86,12 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
$e = new BadRequestHttpException($e->getMessage(), $e);
}
if (false === $catch) {
$this->finishRequest($request, $type);
$this->finishRequest($request, $type, $controllerMetadata);
throw $e;
}
return $response = $this->handleThrowable($e, $request, $type);
return $response = $this->handleThrowable($e, $request, $type, $controllerMetadata);
} finally {
$this->requestStack->pop();
@@ -152,7 +155,7 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
* @throws \LogicException If one of the listener does not behave as expected
* @throws NotFoundHttpException When controller cannot be found
*/
private function handleRaw(Request $request, int $type = self::MAIN_REQUEST): Response
private function handleRaw(Request $request, int $type = self::MAIN_REQUEST, ?ControllerMetadata &$controllerMetadata = null): Response
{
// request
$event = new RequestEvent($this, $request, $type);
@@ -167,14 +170,16 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
throw new NotFoundHttpException(\sprintf('Unable to find the controller for path "%s". The route is wrongly configured.', $request->getPathInfo()));
}
$event = new ControllerEvent($this, $controller, $request, $type);
$controllerEvent = $event = new ControllerEvent($this, $controller, $request, $type);
$controllerMetadata = new ControllerMetadata($event);
$this->dispatcher->dispatch($event, KernelEvents::CONTROLLER);
$controller = $event->getController();
// controller arguments
$arguments = $this->argumentResolver->getArguments($request, $controller, $event->getControllerReflector());
$controllerArgumentsEvent = $event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
$event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
$controllerMetadata = new ControllerArgumentsMetadata($controllerEvent, $event);
$this->dispatcher->dispatch($event, KernelEvents::CONTROLLER_ARGUMENTS);
$controller = $event->getController();
$arguments = $event->getArguments();
@@ -184,7 +189,7 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
// view
if (!$response instanceof Response) {
$event = new ViewEvent($this, $request, $type, $response, $event);
$event = new ViewEvent($this, $request, $type, $response, $controllerMetadata);
$this->dispatcher->dispatch($event, KernelEvents::VIEW);
if ($event->hasResponse()) {
@@ -201,7 +206,7 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
}
}
return $this->filterResponse($response, $request, $type, $controllerArgumentsEvent);
return $this->filterResponse($response, $request, $type, $controllerMetadata);
}
/**
@@ -209,13 +214,13 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
*
* @throws \RuntimeException if the passed object is not a Response instance
*/
private function filterResponse(Response $response, Request $request, int $type, ?ControllerArgumentsEvent $controllerArgumentsEvent = null): Response
private function filterResponse(Response $response, Request $request, int $type, ?ControllerMetadata $controllerMetadata = null): Response
{
$event = new ResponseEvent($this, $request, $type, $response, $controllerArgumentsEvent);
$event = new ResponseEvent($this, $request, $type, $response, $controllerMetadata);
$this->dispatcher->dispatch($event, KernelEvents::RESPONSE);
$this->finishRequest($request, $type);
$this->finishRequest($request, $type, $controllerMetadata);
return $event->getResponse();
}
@@ -227,24 +232,24 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
* operations such as {@link RequestStack::getParentRequest()} can lead to
* weird results.
*/
private function finishRequest(Request $request, int $type): void
private function finishRequest(Request $request, int $type, ?ControllerMetadata $controllerMetadata = null): void
{
$this->dispatcher->dispatch(new FinishRequestEvent($this, $request, $type), KernelEvents::FINISH_REQUEST);
$this->dispatcher->dispatch(new FinishRequestEvent($this, $request, $type, $controllerMetadata), KernelEvents::FINISH_REQUEST);
}
/**
* Handles a throwable by trying to convert it to a Response.
*/
private function handleThrowable(\Throwable $e, Request $request, int $type): Response
private function handleThrowable(\Throwable $e, Request $request, int $type, ?ControllerMetadata $controllerMetadata = null): Response
{
$event = new ExceptionEvent($this, $request, $type, $e, isKernelTerminating: $this->terminating);
$event = new ExceptionEvent($this, $request, $type, $e, isKernelTerminating: $this->terminating, controllerMetadata: $controllerMetadata);
$this->dispatcher->dispatch($event, KernelEvents::EXCEPTION);
// a listener might have replaced the exception
$e = $event->getThrowable();
if (!$event->hasResponse()) {
$this->finishRequest($request, $type);
$this->finishRequest($request, $type, $controllerMetadata);
throw $e;
}
@@ -264,7 +269,7 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
}
try {
return $this->filterResponse($response, $request, $type);
return $this->filterResponse($response, $request, $type, $controllerMetadata);
} catch (\Throwable $e) {
if ($e instanceof \Error && !$this->handleAllThrowables) {
throw $e;

View File

@@ -31,6 +31,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\HttpKernel;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Tests\Fixtures\Attribute\Bar;
class HttpKernelTest extends TestCase
{
@@ -492,6 +493,83 @@ class HttpKernelTest extends TestCase
Request::setTrustedProxies([], -1);
}
public function testResponseEventCanAccessControllerAttributes()
{
$dispatcher = new EventDispatcher();
$capturedAttributes = null;
$dispatcher->addListener(KernelEvents::CONTROLLER, static function ($event) {
$event->setController($event->getController(), [new Bar('test')]);
});
$dispatcher->addListener(KernelEvents::RESPONSE, static function ($event) use (&$capturedAttributes) {
$capturedAttributes = $event->controllerMetadata->getAttributes('*');
});
$kernel = $this->getHttpKernel($dispatcher);
$kernel->handle(new Request(), HttpKernelInterface::MAIN_REQUEST, false);
$this->assertEquals([new Bar('test')], $capturedAttributes);
}
public function testViewEventProvidesControllerArgumentsViaMetadata()
{
$dispatcher = new EventDispatcher();
$capturedArguments = $capturedNamedArguments = null;
$dispatcher->addListener(KernelEvents::VIEW, static function ($event) use (&$capturedArguments, &$capturedNamedArguments) {
$capturedArguments = $event->controllerMetadata->getArguments();
$capturedNamedArguments = $event->controllerMetadata->getNamedArguments();
$event->setResponse(new Response('ok'));
});
$kernel = $this->getHttpKernel($dispatcher, static fn ($value) => $value, arguments: ['resolved']);
$kernel->handle(new Request(), HttpKernelInterface::MAIN_REQUEST, false);
$this->assertSame(['resolved'], $capturedArguments);
$this->assertSame(['value' => 'resolved'], $capturedNamedArguments);
}
public function testExceptionEventProvidesControllerMetadata()
{
$dispatcher = new EventDispatcher();
$capturedController = $capturedArguments = null;
$controller = static fn (string $value) => throw new \RuntimeException('boom');
$dispatcher->addListener(KernelEvents::EXCEPTION, static function ($event) use (&$capturedController, &$capturedArguments) {
$capturedController = $event->controllerMetadata->getController();
$capturedArguments = $event->controllerMetadata->getArguments();
$event->setResponse(new Response('handled'));
});
$kernel = $this->getHttpKernel($dispatcher, $controller, arguments: ['meta']);
$response = $kernel->handle(new Request(), HttpKernelInterface::MAIN_REQUEST, true);
$this->assertSame('handled', $response->getContent());
$this->assertSame($controller, $capturedController);
$this->assertSame(['meta'], $capturedArguments);
}
public function testFinishRequestEventKeepsControllerMetadata()
{
$dispatcher = new EventDispatcher();
$capturedArguments = null;
$dispatcher->addListener(KernelEvents::FINISH_REQUEST, static function ($event) use (&$capturedArguments) {
$capturedArguments = $event->controllerMetadata->getArguments();
});
$kernel = $this->getHttpKernel($dispatcher, static fn ($value) => new Response($value), arguments: ['done']);
$response = $kernel->handle(new Request(), HttpKernelInterface::MAIN_REQUEST, false);
$this->assertSame('done', $response->getContent());
$this->assertSame(['done'], $capturedArguments);
}
private function getHttpKernel(EventDispatcherInterface $eventDispatcher, $controller = null, ?RequestStack $requestStack = null, array $arguments = [], bool $handleAllThrowables = false)
{
$controller ??= static fn () => new Response('Hello');