Skip to content

Commit 10bc796

Browse files
feature #54016 [DependencyInjection] Add #[AutowireMethodOf] attribute to autowire a method of a service as a callable (nicolas-grekas)
This PR was merged into the 7.1 branch. Discussion ---------- [DependencyInjection] Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable | Q | A | ------------- | --- | Branch? | 7.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT Let's take a controller for example, with one action that has this argument: ```php #[AutowireMethodOf(CommentRepository::class)] \Closure $getCommentPaginator, ``` The proposed attribute tells Symfony to create a closure that will call `CommentRepository::getCommentPaginator()`. The name of the method to be called is inferred from the name of the parameter. This is already doable with this syntax, so that the proposed attribute is just a shortcut for this: ```php #[AutowireCallable(service: CommentRepository::class, method: 'getCommentPaginator')] \Closure $getCommentPaginator, ``` Using this style allows turning e.g. entity repositories into query functions, which are way more flexible. But because the existing syntax is quite verbose, i looked for a more concise alternative, so here we are with this proposal. Benefits: - Increased Flexibility: Allows developers to inject specific methods as Closures, providing greater control over what functionality is injected into - Improved Readability: By using the attribute, the intention behind the dependency injection is made explicit, improving code clarity. - **Enhanced Modularity: Facilitates a more modular architecture by decoupling services from direct dependencies on specific class methods, making the codebase more maintainable and testable.** Because we leverage the existing code infrastructure for AutowireCallable, if I declare this interface: ```php interface GetCommentPaginatorInterface { public function __invoke(Conference $conference, int $page): Paginator; } ``` then I can also do native types (vs a closure) without doing anything else: ```php #[AutowireMethodOf(CommentRepository::class)] GetCommentPaginatorInterface $getCommentPaginator, ``` Commits ------- df11660 [DependencyInjection] Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable
2 parents 719803d + df11660 commit 10bc796

File tree

10 files changed

+103
-18
lines changed

10 files changed

+103
-18
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Attribute;
13+
14+
use Symfony\Component\DependencyInjection\Definition;
15+
use Symfony\Component\DependencyInjection\Reference;
16+
17+
/**
18+
* Tells which method should be turned into a Closure based on the name of the parameter it's attached to.
19+
*/
20+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
21+
class AutowireMethodOf extends AutowireCallable
22+
{
23+
/**
24+
* @param string $service The service containing the method to autowire
25+
* @param bool|class-string $lazy Whether to use lazy-loading for this argument
26+
*/
27+
public function __construct(string $service, bool|string $lazy = false)
28+
{
29+
parent::__construct([new Reference($service)], lazy: $lazy);
30+
}
31+
32+
public function buildDefinition(mixed $value, ?string $type, \ReflectionParameter $parameter): Definition
33+
{
34+
$value[1] = $parameter->name;
35+
36+
return parent::buildDefinition($value, $type, $parameter);
37+
}
38+
}

src/Symfony/Component/DependencyInjection/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Add argument `$prepend` to `ContainerConfigurator::extension()` to prepend the configuration instead of appending it
99
* Have `ServiceLocator` implement `ServiceCollectionInterface`
1010
* Add `#[Lazy]` attribute as shortcut for `#[Autowire(lazy: [bool|string])]` and `#[Autoconfigure(lazy: [bool|string])]`
11+
* Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable
1112

1213
7.0
1314
---

src/Symfony/Component/DependencyInjection/Compiler/CheckExceptionOnInvalidReferenceBehaviorPass.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
4747
if (!$value instanceof Reference) {
4848
return parent::processValue($value, $isRoot);
4949
}
50-
if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE < $value->getInvalidBehavior() || $this->container->has($id = (string) $value)) {
50+
if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE < $value->getInvalidBehavior() || $this->container->has((string) $value)) {
5151
return $value;
5252
}
5353

@@ -83,7 +83,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
8383
$this->throwServiceNotFoundException($value, $currentId, $value);
8484
}
8585

86-
private function throwServiceNotFoundException(Reference $ref, string $sourceId, $value): void
86+
private function throwServiceNotFoundException(Reference $ref, string $sourceId, mixed $value): void
8787
{
8888
$id = (string) $ref;
8989
$alternatives = [];
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Tests\Attribute;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\DependencyInjection\Attribute\AutowireMethodOf;
16+
use Symfony\Component\DependencyInjection\Reference;
17+
18+
class AutowireMethodOfTest extends TestCase
19+
{
20+
public function testConstructor()
21+
{
22+
$a = new AutowireMethodOf('foo');
23+
24+
$this->assertEquals([new Reference('foo')], $a->value);
25+
}
26+
27+
public function testBuildDefinition(?\Closure $dummy = null)
28+
{
29+
$a = new AutowireMethodOf('foo');
30+
$r = new \ReflectionParameter([__CLASS__, __FUNCTION__], 0);
31+
32+
$this->assertEquals([[new Reference('foo'), 'dummy']], $a->buildDefinition($a->value, 'Closure', $r)->getArguments());
33+
}
34+
}

src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ public function resolve(Request $request, ArgumentMetadata $argument): array
5555
try {
5656
return [$this->container->get($controller)->get($argument->getName())];
5757
} catch (RuntimeException $e) {
58-
$what = sprintf('argument $%s of "%s()"', $argument->getName(), $controller);
59-
$message = preg_replace('/service "\.service_locator\.[^"]++"/', $what, $e->getMessage());
58+
$what = 'argument $'.$argument->getName();
59+
$message = str_replace(sprintf('service "%s"', $argument->getName()), $what, $e->getMessage());
60+
$what .= sprintf(' of "%s()"', $controller);
61+
$message = preg_replace('/service "\.service_locator\.[^"]++"/', $what, $message);
6062

6163
if ($e->getMessage() === $message) {
6264
$message = sprintf('Cannot resolve %s: %s', $what, $message);

src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public function process(ContainerBuilder $container): void
123123

124124
// create a per-method map of argument-names to service/type-references
125125
$args = [];
126+
$erroredIds = 0;
126127
foreach ($parameters as $p) {
127128
/** @var \ReflectionParameter $p */
128129
$type = preg_replace('/(^|[(|&])\\\\/', '\1', $target = ltrim(ProxyHelper::exportType($p) ?? '', '?'));
@@ -171,10 +172,8 @@ public function process(ContainerBuilder $container): void
171172
$value = $parameterBag->resolveValue($attribute->value);
172173

173174
if ($attribute instanceof AutowireCallable) {
174-
$value = $attribute->buildDefinition($value, $type, $p);
175-
}
176-
177-
if ($value instanceof Reference) {
175+
$args[$p->name] = $attribute->buildDefinition($value, $type, $p);
176+
} elseif ($value instanceof Reference) {
178177
$args[$p->name] = $type ? new TypedReference($value, $type, $invalidBehavior, $p->name) : new Reference($value, $invalidBehavior);
179178
} else {
180179
$args[$p->name] = new Reference('.value.'.$container->hash($value));
@@ -198,14 +197,15 @@ public function process(ContainerBuilder $container): void
198197
->addError($message);
199198

200199
$args[$p->name] = new Reference($erroredId, ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE);
200+
++$erroredIds;
201201
} else {
202202
$target = preg_replace('/(^|[(|&])\\\\/', '\1', $target);
203203
$args[$p->name] = $type ? new TypedReference($target, $type, $invalidBehavior, Target::parseName($p)) : new Reference($target, $invalidBehavior);
204204
}
205205
}
206206
// register the maps as a per-method service-locators
207207
if ($args) {
208-
$controllers[$id.'::'.$r->name] = ServiceLocatorTagPass::register($container, $args);
208+
$controllers[$id.'::'.$r->name] = ServiceLocatorTagPass::register($container, $args, \count($args) !== $erroredIds ? $id.'::'.$r->name.'()' : null);
209209

210210
foreach ($publicAliases[$id] ?? [] as $alias) {
211211
$controllers[$alias.'::'.$r->name] = clone $controllers[$id.'::'.$r->name];

src/Symfony/Component/HttpKernel/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPass.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ public function process(ContainerBuilder $container): void
2929
foreach ($controllers as $controller => $argumentRef) {
3030
$argumentLocator = $container->getDefinition((string) $argumentRef->getValues()[0]);
3131

32+
if ($argumentLocator->getFactory()) {
33+
$argumentLocator = $container->getDefinition($argumentLocator->getFactory()[0]);
34+
}
35+
3236
if (!$argumentLocator->getArgument(0)) {
3337
// remove empty argument locators
3438
$reason = sprintf('Removing service-argument resolver for controller "%s": no corresponding services exist for the referenced types.', $controller);

src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public function testControllerNameIsAnArray()
8989
public function testErrorIsTruncated()
9090
{
9191
$this->expectException(NearMissValueResolverException::class);
92-
$this->expectExceptionMessage('Cannot autowire argument $dummy of "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyController::index()": it references class "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyService" but no such service exists.');
92+
$this->expectExceptionMessage('Cannot autowire argument $dummy required by "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyController::index()": it references class "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyService" but no such service exists.');
9393
$container = new ContainerBuilder();
9494
$container->addCompilerPass(new RegisterControllerArgumentLocatorsPass());
9595

src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ public function testAllActions()
143143
$this->assertInstanceof(ServiceClosureArgument::class, $locator['foo::fooAction']);
144144

145145
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
146+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
146147

147148
$this->assertSame(ServiceLocator::class, $locator->getClass());
148149
$this->assertFalse($locator->isPublic());
@@ -166,6 +167,7 @@ public function testExplicitArgument()
166167

167168
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
168169
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
170+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
169171

170172
$expected = ['bar' => new ServiceClosureArgument(new TypedReference('bar', ControllerDummy::class, ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE))];
171173
$this->assertEquals($expected, $locator->getArgument(0));
@@ -185,6 +187,7 @@ public function testOptionalArgument()
185187

186188
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
187189
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
190+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
188191

189192
$expected = ['bar' => new ServiceClosureArgument(new TypedReference('bar', ControllerDummy::class, ContainerInterface::IGNORE_ON_INVALID_REFERENCE))];
190193
$this->assertEquals($expected, $locator->getArgument(0));
@@ -306,8 +309,8 @@ public function testBindings($bindingName)
306309
$pass->process($container);
307310

308311
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
309-
310312
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
313+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
311314

312315
$expected = ['bar' => new ServiceClosureArgument(new Reference('foo'))];
313316
$this->assertEquals($expected, $locator->getArgument(0));
@@ -372,7 +375,8 @@ public function testBindingsOnChildDefinitions()
372375
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
373376
$this->assertInstanceOf(ServiceClosureArgument::class, $locator['child::fooAction']);
374377

375-
$locator = $container->getDefinition((string) $locator['child::fooAction']->getValues()[0])->getArgument(0);
378+
$locator = $container->getDefinition((string) $locator['child::fooAction']->getValues()[0]);
379+
$locator = $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0);
376380
$this->assertInstanceOf(ServiceClosureArgument::class, $locator['someArg']);
377381
$this->assertEquals(new Reference('parent'), $locator['someArg']->getValues()[0]);
378382
}
@@ -439,6 +443,7 @@ public function testBindWithTarget()
439443

440444
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
441445
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
446+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
442447

443448
$expected = [
444449
'apiKey' => new ServiceClosureArgument(new Reference('the_api_key')),

src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPassTest.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,23 @@ public function testProcess()
3535
$pass->process($container);
3636

3737
$controllers = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
38+
$getLocator = fn ($controllers, $k) => $container->getDefinition((string) $container->getDefinition((string) $controllers[$k]->getValues()[0])->getFactory()[0])->getArgument(0);
3839

39-
$this->assertCount(2, $container->getDefinition((string) $controllers['c1::fooAction']->getValues()[0])->getArgument(0));
40-
$this->assertCount(1, $container->getDefinition((string) $controllers['c2::setTestCase']->getValues()[0])->getArgument(0));
41-
$this->assertCount(1, $container->getDefinition((string) $controllers['c2::fooAction']->getValues()[0])->getArgument(0));
40+
$this->assertCount(2, $getLocator($controllers, 'c1::fooAction'));
41+
$this->assertCount(1, $getLocator($controllers, 'c2::setTestCase'));
42+
$this->assertCount(1, $getLocator($controllers, 'c2::fooAction'));
4243

4344
(new ResolveInvalidReferencesPass())->process($container);
4445

45-
$this->assertCount(1, $container->getDefinition((string) $controllers['c2::setTestCase']->getValues()[0])->getArgument(0));
46-
$this->assertSame([], $container->getDefinition((string) $controllers['c2::fooAction']->getValues()[0])->getArgument(0));
46+
$this->assertCount(1, $getLocator($controllers, 'c2::setTestCase'));
47+
$this->assertSame([], $getLocator($controllers, 'c2::fooAction'));
4748

4849
(new RemoveEmptyControllerArgumentLocatorsPass())->process($container);
4950

5051
$controllers = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
5152

5253
$this->assertSame(['c1::fooAction', 'c1:fooAction'], array_keys($controllers));
53-
$this->assertSame(['bar'], array_keys($container->getDefinition((string) $controllers['c1::fooAction']->getValues()[0])->getArgument(0)));
54+
$this->assertSame(['bar'], array_keys($getLocator($controllers, 'c1::fooAction')));
5455

5556
$expectedLog = [
5657
'Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass: Removing service-argument resolver for controller "c2::fooAction": no corresponding services exist for the referenced types.',

0 commit comments

Comments
 (0)