[HttpKernel] Return attributes as a flat list when using Controller[Arguments]Event::getAttributes(*)

This commit is contained in:
Nicolas Grekas
2026-01-14 15:18:11 +01:00
parent c872e9e8a8
commit 92bf162ebd
10 changed files with 121 additions and 182 deletions

View File

@@ -7,11 +7,13 @@ CHANGELOG
* Add support for `UploadedFile` when using `MapRequestPayload`
* Add support for bundles as compiler pass
* Add support for `SOURCE_DATE_EPOCH` environment variable
* Add `ResponseEvent::getControllerAttributes()`
* Add property `$controllerArgumentsEvent` to `ResponseEvent`
* 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
* Allow using closures with the `Cache` attribute
* Allow setting a condition when the `Cache` attribute should be applied
* Deprecate passing a non-flat list of attributes to `Controller::setController()`
8.0
---

View File

@@ -53,7 +53,7 @@ final class ControllerArgumentsEvent extends KernelEvent
}
/**
* @param array<class-string, list<object>>|null $attributes
* @param list<object>|null $attributes
*/
public function setController(callable $controller, ?array $attributes = null): void
{
@@ -99,9 +99,9 @@ final class ControllerArgumentsEvent extends KernelEvent
/**
* @template T of object
*
* @param class-string<T>|null $className
* @param class-string<T>|'*'|null $className
*
* @return ($className is null ? array<class-string, list<object>> : list<T>)
* @return ($className is null ? array<class-string, list<object>> : ($className is '*' ? list<object> : list<T>))
*/
public function getAttributes(?string $className = null): array
{

View File

@@ -48,12 +48,21 @@ final class ControllerEvent extends KernelEvent
}
/**
* @param array<class-string, list<object>>|null $attributes
* @param list<object>|null $attributes
*/
public function setController(callable $controller, ?array $attributes = null): void
{
if (null !== $attributes) {
$this->getRequest()->attributes->set('_controller_attributes', $attributes);
if (!array_is_list($flattenAttributes = $attributes)) {
trigger_deprecation('symfony/http-kernel', '8.1', 'Passing an array of attributes grouped by class name to "%s()" is deprecated. Pass a flat list of attributes instead.', __METHOD__);
$flattenAttributes = [];
foreach ($attributes as $attributes) {
foreach (\is_array($attributes) ? $attributes : [$attributes] as $attribute) {
$flattenAttributes[] = $attribute;
}
}
}
$this->getRequest()->attributes->set('_controller_attributes', $flattenAttributes);
}
if (isset($this->controller) && ($controller instanceof \Closure ? $controller == $this->controller : $controller === $this->controller)) {
@@ -66,13 +75,11 @@ final class ControllerEvent extends KernelEvent
$this->getRequest()->attributes->remove('_controller_attributes');
}
if (\is_array($controller) && method_exists(...$controller)) {
$this->controllerReflector = new \ReflectionMethod(...$controller);
} elseif (\is_string($controller) && str_contains($controller, '::')) {
$this->controllerReflector = new \ReflectionMethod(...explode('::', $controller, 2));
} else {
$this->controllerReflector = new \ReflectionFunction($controller(...));
}
$this->controllerReflector = match (true) {
\is_array($controller) && method_exists(...$controller) => new \ReflectionMethod(...$controller),
\is_string($controller) && str_contains($controller, '::') => new \ReflectionMethod(...explode('::', $controller, 2)),
default => new \ReflectionFunction($controller(...)),
};
$this->controller = $controller;
}
@@ -80,33 +87,42 @@ final class ControllerEvent extends KernelEvent
/**
* @template T of object
*
* @param class-string<T>|null $className
* @param class-string<T>|'*'|null $className
*
* @return ($className is null ? array<class-string, list<object>> : list<T>)
* @return ($className is null ? array<class-string, list<object>> : ($className is '*' ? list<object> : list<T>))
*/
public function getAttributes(?string $className = null): array
{
if (null !== $attributes = $this->getRequest()->attributes->get('_controller_attributes')) {
return null === $className ? $attributes : $attributes[$className] ?? [];
}
if (null === $attributes = $this->getRequest()->attributes->get('_controller_attributes')) {
$class = match (true) {
\is_array($this->controller) && method_exists(...$this->controller) => new \ReflectionClass($this->controller[0]),
\is_string($this->controller) && false !== $i = strpos($this->controller, '::') => new \ReflectionClass(substr($this->controller, 0, $i)),
$this->controllerReflector instanceof \ReflectionFunction => $this->controllerReflector->isAnonymous() ? null : $this->controllerReflector->getClosureCalledClass(),
};
$attributes = [];
if (\is_array($this->controller) && method_exists(...$this->controller)) {
$class = new \ReflectionClass($this->controller[0]);
} elseif (\is_string($this->controller) && false !== $i = strpos($this->controller, '::')) {
$class = new \ReflectionClass(substr($this->controller, 0, $i));
} else {
$class = $this->controllerReflector instanceof \ReflectionFunction && $this->controllerReflector->isAnonymous() ? null : $this->controllerReflector->getClosureCalledClass();
}
$attributes = [];
foreach (array_merge($class?->getAttributes() ?? [], $this->controllerReflector->getAttributes()) as $attribute) {
if (class_exists($attribute->getName())) {
$attributes[$attribute->getName()][] = $attribute->newInstance();
foreach (array_merge($class?->getAttributes() ?? [], $this->controllerReflector->getAttributes()) as $attribute) {
if (class_exists($attribute->getName())) {
$attributes[] = $attribute->newInstance();
}
}
$this->getRequest()->attributes->set('_controller_attributes', $attributes);
}
$this->getRequest()->attributes->set('_controller_attributes', $attributes);
if ('*' === $className) {
return $attributes;
}
return null === $className ? $attributes : $attributes[$className] ?? [];
if (null !== $className) {
return array_values(array_filter($attributes, static fn ($attr) => $attr instanceof $className));
}
$grouped = [];
foreach ($attributes as $attribute) {
$grouped[$attribute::class][] = $attribute;
}
return $grouped;
}
}

View File

@@ -31,7 +31,7 @@ final class ResponseEvent extends KernelEvent
Request $request,
int $requestType,
private Response $response,
private ?ControllerEvent $controllerEvent = null,
public readonly ?ControllerArgumentsEvent $controllerArgumentsEvent = null,
) {
parent::__construct($kernel, $request, $requestType);
}
@@ -45,18 +45,4 @@ final class ResponseEvent extends KernelEvent
{
$this->response = $response;
}
/**
* @template T of class-string|null
*
* @param T $className
*
* @return array<class-string, list<object>>|list<object>
*
* @psalm-return (T is null ? array<class-string, list<object>> : list<object>)
*/
public function getControllerAttributes(?string $className = null): array
{
return $this->controllerEvent?->getAttributes($className) ?? [];
}
}

View File

@@ -167,14 +167,14 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
throw new NotFoundHttpException(\sprintf('Unable to find the controller for path "%s". The route is wrongly configured.', $request->getPathInfo()));
}
$controllerEvent = $event = new ControllerEvent($this, $controller, $request, $type);
$event = new ControllerEvent($this, $controller, $request, $type);
$this->dispatcher->dispatch($event, KernelEvents::CONTROLLER);
$controller = $event->getController();
// controller arguments
$arguments = $this->argumentResolver->getArguments($request, $controller, $event->getControllerReflector());
$event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
$controllerArgumentsEvent = $event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
$this->dispatcher->dispatch($event, KernelEvents::CONTROLLER_ARGUMENTS);
$controller = $event->getController();
$arguments = $event->getArguments();
@@ -201,7 +201,7 @@ class HttpKernel implements HttpKernelInterface, TerminableInterface
}
}
return $this->filterResponse($response, $request, $type, $controllerEvent);
return $this->filterResponse($response, $request, $type, $controllerArgumentsEvent);
}
/**
@@ -209,9 +209,9 @@ 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, ?ControllerEvent $controllerEvent = null): Response
private function filterResponse(Response $response, Request $request, int $type, ?ControllerArgumentsEvent $controllerArgumentsEvent = null): Response
{
$event = new ResponseEvent($this, $request, $type, $response, $controllerEvent);
$event = new ResponseEvent($this, $request, $type, $response, $controllerArgumentsEvent);
$this->dispatcher->dispatch($event, KernelEvents::RESPONSE);

View File

@@ -59,10 +59,26 @@ class ControllerArgumentsEventTest extends TestCase
$this->assertEquals($expected, $event->getAttributes());
$expected[Bar::class][] = new Bar('foo');
$event->setController($controller, $expected);
$attributes = [
new Bar('class'),
new Bar('method'),
new Bar('foo'),
new Baz(),
];
$event->setController($controller, $attributes);
$this->assertEquals($expected, $event->getAttributes());
$grouped = [
Bar::class => [
new Bar('class'),
new Bar('method'),
new Bar('foo'),
],
Baz::class => [
new Baz(),
],
];
$this->assertEquals($grouped, $event->getAttributes());
$this->assertEquals($attributes, $event->getAttributes('*'));
$this->assertSame($controllerEvent->getAttributes(), $event->getAttributes());
}
@@ -82,10 +98,20 @@ class ControllerArgumentsEventTest extends TestCase
$this->assertEquals($expected, $event->getAttributes(Bar::class));
$expected[] = new Bar('foo');
$event->setController($controller, [Bar::class => $expected]);
// When setting attributes, provide as flat list
$flatAttributes = [
new Bar('class'),
new Bar('method'),
new Bar('foo'),
];
$event->setController($controller, $flatAttributes);
$this->assertEquals($expected, $event->getAttributes(Bar::class));
$expectedAfterSet = [
new Bar('class'),
new Bar('method'),
new Bar('foo'),
];
$this->assertEquals($expectedAfterSet, $event->getAttributes(Bar::class));
$this->assertSame($controllerEvent->getAttributes(Bar::class), $event->getAttributes(Bar::class));
}
}

View File

@@ -12,6 +12,8 @@
namespace Symfony\Component\HttpKernel\Tests\Event;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -76,7 +78,11 @@ class ControllerEventTest extends TestCase
$attributes = $event->getAttributes();
$this->assertTrue($request->attributes->has('_controller_attributes'));
$this->assertEquals($attributes, $request->attributes->get('_controller_attributes'));
$stored = $request->attributes->get('_controller_attributes');
$this->assertIsArray($stored);
$this->assertCount(3, $stored);
$this->assertIsArray($attributes);
$this->assertArrayHasKey(Bar::class, $attributes);
}
public function testSetControllerWithAttributesStoresInRequest()
@@ -85,11 +91,33 @@ class ControllerEventTest extends TestCase
$controller = [new AttributeController(), '__invoke'];
$event = new ControllerEvent(new TestHttpKernel(), $controller, $request, HttpKernelInterface::MAIN_REQUEST);
$customAttributes = [Bar::class => [new Bar('custom')]];
// Provide attributes as flat list
$customAttributes = [new Bar('custom')];
$event->setController($controller, $customAttributes);
$this->assertEquals($customAttributes, $request->attributes->get('_controller_attributes'));
$stored = $request->attributes->get('_controller_attributes');
$this->assertIsArray($stored);
$this->assertCount(1, $stored);
$this->assertInstanceOf(Bar::class, $stored[0]);
}
#[IgnoreDeprecations]
#[Group('legacy')]
public function testSetControllerWithGroupedAttributesConvertsToFlat()
{
$request = new Request();
$controller = [new AttributeController(), '__invoke'];
$event = new ControllerEvent(new TestHttpKernel(), $controller, $request, HttpKernelInterface::MAIN_REQUEST);
$groupedAttributes = [Bar::class => [new Bar('custom')]];
$event->setController($controller, $groupedAttributes);
$stored = $request->attributes->get('_controller_attributes');
$this->assertIsArray($stored);
$this->assertCount(1, $stored);
$this->assertInstanceOf(Bar::class, $stored[0]);
}
public function testSetControllerWithoutAttributesRemovesFromRequestWhenControllerChanges()
@@ -100,7 +128,7 @@ class ControllerEventTest extends TestCase
$event = new ControllerEvent(new TestHttpKernel(), $controller1, $request, HttpKernelInterface::MAIN_REQUEST);
// First set some attributes
$customAttributes = [Bar::class => [new Bar('custom')]];
$customAttributes = [new Bar('custom')];
$event->setController($controller1, $customAttributes);
$this->assertEquals($customAttributes, $request->attributes->get('_controller_attributes'));

View File

@@ -1,95 +0,0 @@
<?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\Tests\Event;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Tests\Fixtures\Attribute\Bar;
use Symfony\Component\HttpKernel\Tests\Fixtures\Attribute\Baz;
use Symfony\Component\HttpKernel\Tests\Fixtures\Controller\AttributeController;
use Symfony\Component\HttpKernel\Tests\TestHttpKernel;
class ResponseEventTest extends TestCase
{
public function testGetControllerAttributesWithoutControllerEvent()
{
$kernel = new TestHttpKernel();
$request = new Request();
$response = new Response();
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response);
$this->assertEquals([], $event->getControllerAttributes());
$this->assertEquals([], $event->getControllerAttributes(Bar::class));
}
public function testGetControllerAttributesWithControllerEvent()
{
$kernel = new TestHttpKernel();
$request = new Request();
$response = new Response();
$controller = [new AttributeController(), '__invoke'];
$controllerEvent = new ControllerEvent($kernel, $controller, $request, HttpKernelInterface::MAIN_REQUEST);
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response, $controllerEvent);
$expected = [
Bar::class => [
new Bar('class'),
new Bar('method'),
],
Baz::class => [
new Baz(),
],
];
$this->assertEquals($expected, $event->getControllerAttributes());
}
public function testGetControllerAttributesByClassName()
{
$kernel = new TestHttpKernel();
$request = new Request();
$response = new Response();
$controller = [new AttributeController(), '__invoke'];
$controllerEvent = new ControllerEvent($kernel, $controller, $request, HttpKernelInterface::MAIN_REQUEST);
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response, $controllerEvent);
$expected = [
new Bar('class'),
new Bar('method'),
];
$this->assertEquals($expected, $event->getControllerAttributes(Bar::class));
}
public function testGetControllerAttributesByInvalidClassName()
{
$kernel = new TestHttpKernel();
$request = new Request();
$response = new Response();
$controller = [new AttributeController(), '__invoke'];
$controllerEvent = new ControllerEvent($kernel, $controller, $request, HttpKernelInterface::MAIN_REQUEST);
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response, $controllerEvent);
$this->assertEquals([], $event->getControllerAttributes(\stdClass::class));
}
}

View File

@@ -31,7 +31,6 @@ 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
{
@@ -493,30 +492,6 @@ class HttpKernelTest extends TestCase
Request::setTrustedProxies([], -1);
}
public function testResponseEventCanAccessControllerAttributes()
{
$dispatcher = new EventDispatcher();
$capturedAttributes = null;
$dispatcher->addListener(KernelEvents::CONTROLLER, static function ($event) {
// Set some attributes on the controller event
$event->setController($event->getController(), [Bar::class => [new Bar('test')]]);
});
$dispatcher->addListener(KernelEvents::RESPONSE, static function ($event) use (&$capturedAttributes) {
$capturedAttributes = $event->getControllerAttributes();
});
$kernel = $this->getHttpKernel($dispatcher);
$kernel->handle(new Request(), HttpKernelInterface::MAIN_REQUEST, false);
// Should have the attributes we set
$this->assertIsArray($capturedAttributes);
$this->assertArrayHasKey(Bar::class, $capturedAttributes);
$this->assertEquals([new Bar('test')], $capturedAttributes[Bar::class]);
}
private function getHttpKernel(EventDispatcherInterface $eventDispatcher, $controller = null, ?RequestStack $requestStack = null, array $arguments = [], bool $handleAllThrowables = false)
{
$controller ??= static fn () => new Response('Hello');

View File

@@ -18,6 +18,7 @@
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/error-handler": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",