Skip to content

Commit cdb18ad

Browse files
committed
feature symfony#49685 [DependencyInjection] Add support for #[Autowire(lazy: true)] (nicolas-grekas)
This PR was merged into the 6.3 branch. Discussion ---------- [DependencyInjection] Add support for `#[Autowire(lazy: true)]` | Q | A | ------------- | --- | Branch? | 6.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | - This is a feature that I anticipated during my talk at SymfonyCon DisneyLand. This PR adds support for using `#[Autowire(lazy: true)]` on an argument to tell the container to generate a lazy service and pass it to that argument. This works by creating a lazy service from the autowiring alias: if the argument targets `FooInterface`, a lazy service is declared (named `.lazy.FooInterface`) and injected. The original `FooInterface` remains unchanged, aka non-lazy. This allows services that don't always consume their deps to initialize them only when actually needed. Commits ------- 610de6a [DependencyInjection] Add support for `#[Autowire(lazy: true)]`
2 parents 7188432 + 610de6a commit cdb18ad

File tree

8 files changed

+174
-28
lines changed

8 files changed

+174
-28
lines changed

src/Symfony/Component/DependencyInjection/Attribute/Autowire.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
#[\Attribute(\Attribute::TARGET_PARAMETER)]
2525
class Autowire
2626
{
27-
public readonly string|array|Expression|Reference|ArgumentInterface $value;
27+
public readonly string|array|Expression|Reference|ArgumentInterface|null $value;
2828

2929
/**
3030
* Use only ONE of the following.
@@ -41,8 +41,12 @@ public function __construct(
4141
string $expression = null,
4242
string $env = null,
4343
string $param = null,
44+
public bool $lazy = false,
4445
) {
45-
if (!(null !== $value xor null !== $service xor null !== $expression xor null !== $env xor null !== $param)) {
46+
if ($lazy && null !== ($expression ?? $env ?? $param)) {
47+
throw new LogicException('#[Autowire] attribute cannot be $lazy and use $env, $param, or $value.');
48+
}
49+
if (!$lazy && !(null !== $value xor null !== $service xor null !== $expression xor null !== $env xor null !== $param)) {
4650
throw new LogicException('#[Autowire] attribute must declare exactly one of $service, $expression, $env, $param or $value.');
4751
}
4852

@@ -59,7 +63,7 @@ public function __construct(
5963
null !== $expression => class_exists(Expression::class) ? new Expression($expression) : throw new LogicException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".'),
6064
null !== $env => "%env($env)%",
6165
null !== $param => "%$param%",
62-
null !== $value => $value,
66+
default => $value,
6367
};
6468
}
6569
}

src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public function __construct(
2424
string|array $callable = null,
2525
string $service = null,
2626
string $method = null,
27-
public bool $lazy = false,
27+
bool $lazy = false,
2828
) {
2929
if (!(null !== $callable xor null !== $service)) {
3030
throw new LogicException('#[AutowireCallable] attribute must declare exactly one of $callable or $service.');
@@ -33,6 +33,6 @@ public function __construct(
3333
throw new LogicException('#[AutowireCallable] attribute must declare one of $callable or $method.');
3434
}
3535

36-
parent::__construct($callable ?? [new Reference($service), $method ?? '__invoke']);
36+
parent::__construct($callable ?? [new Reference($service), $method ?? '__invoke'], lazy: $lazy);
3737
}
3838
}

src/Symfony/Component/DependencyInjection/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ CHANGELOG
1717
* Add `#[Exclude]` to skip autoregistering a class
1818
* Add support for generating lazy closures
1919
* Add support for autowiring services as closures using `#[AutowireCallable]` or `#[AutowireServiceClosure]`
20+
* Add support for `#[Autowire(lazy: true)]`
2021
* Deprecate `#[MapDecorated]`, use `#[AutowireDecorated]` instead
2122
* Deprecate the `@required` annotation, use the `Symfony\Contracts\Service\Attribute\Required` attribute instead
2223

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

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -269,17 +269,38 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a
269269
$name = Target::parseName($parameter, $target);
270270
$target = $target ? [$target] : [];
271271

272+
$getValue = function () use ($type, $parameter, $class, $method, $name, $target) {
273+
if (!$value = $this->getAutowiredReference($ref = new TypedReference($type, $type, ContainerBuilder::EXCEPTION_ON_INVALID_REFERENCE, $name, $target), false)) {
274+
$failureMessage = $this->createTypeNotFoundMessageCallback($ref, sprintf('argument "$%s" of method "%s()"', $parameter->name, $class !== $this->currentId ? $class.'::'.$method : $method));
275+
276+
if ($parameter->isDefaultValueAvailable()) {
277+
$value = clone $this->defaultArgument;
278+
$value->value = $parameter->getDefaultValue();
279+
} elseif (!$parameter->allowsNull()) {
280+
throw new AutowiringFailedException($this->currentId, $failureMessage);
281+
}
282+
}
283+
284+
return $value;
285+
};
286+
272287
if ($checkAttributes) {
273288
foreach ($parameter->getAttributes(Autowire::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
274289
$attribute = $attribute->newInstance();
275290
$invalidBehavior = $parameter->allowsNull() ? ContainerInterface::NULL_ON_INVALID_REFERENCE : ContainerBuilder::EXCEPTION_ON_INVALID_REFERENCE;
276291
$value = $this->processValue(new TypedReference($type ?: '?', $type ?: 'mixed', $invalidBehavior, $name, [$attribute, ...$target]));
277292

278-
if ($attribute instanceof AutowireCallable || 'Closure' === $type && \is_array($value)) {
293+
if ($attribute instanceof AutowireCallable) {
279294
$value = (new Definition('Closure'))
280295
->setFactory(['Closure', 'fromCallable'])
281296
->setArguments([$value + [1 => '__invoke']])
282-
->setLazy($attribute instanceof AutowireCallable && $attribute->lazy);
297+
->setLazy($attribute->lazy);
298+
} elseif ($attribute->lazy && ($value instanceof Reference ? !$this->container->has($value) || !$this->container->findDefinition($value)->isLazy() : null === $attribute->value && $type)) {
299+
$this->container->register('.lazy.'.$value ??= $getValue(), $type)
300+
->setFactory('current')
301+
->setArguments([[$value]])
302+
->setLazy(true);
303+
$value = new Reference('.lazy.'.$value);
283304
}
284305
$arguments[$index] = $value;
285306

@@ -326,21 +347,6 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a
326347
continue;
327348
}
328349

329-
$getValue = function () use ($type, $parameter, $class, $method, $name, $target) {
330-
if (!$value = $this->getAutowiredReference($ref = new TypedReference($type, $type, ContainerBuilder::EXCEPTION_ON_INVALID_REFERENCE, $name, $target), false)) {
331-
$failureMessage = $this->createTypeNotFoundMessageCallback($ref, sprintf('argument "$%s" of method "%s()"', $parameter->name, $class !== $this->currentId ? $class.'::'.$method : $method));
332-
333-
if ($parameter->isDefaultValueAvailable()) {
334-
$value = clone $this->defaultArgument;
335-
$value->value = $parameter->getDefaultValue();
336-
} elseif (!$parameter->allowsNull()) {
337-
throw new AutowiringFailedException($this->currentId, $failureMessage);
338-
}
339-
}
340-
341-
return $value;
342-
};
343-
344350
if ($this->decoratedClass && is_a($this->decoratedClass, $type, true)) {
345351
if ($this->getPreviousValue) {
346352
// The inner service is injected only if there is only 1 argument matching the type of the decorated class

src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ public static function getSubscribedServices(): array
462462
'autowired' => new ServiceClosureArgument(new TypedReference('service.id', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'autowired', [new Autowire(service: 'service.id')])),
463463
'autowired.nullable' => new ServiceClosureArgument(new TypedReference('service.id', 'stdClass', ContainerInterface::IGNORE_ON_INVALID_REFERENCE, 'autowired.nullable', [new Autowire(service: 'service.id')])),
464464
'autowired.parameter' => new ServiceClosureArgument('foobar'),
465-
'autowire.decorated' => new ServiceClosureArgument(new Reference('.service_locator.QVDPERh.inner', ContainerInterface::NULL_ON_INVALID_REFERENCE)),
465+
'autowire.decorated' => new ServiceClosureArgument(new Reference('.service_locator.0tSxobl.inner', ContainerInterface::NULL_ON_INVALID_REFERENCE)),
466466
'target' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'target', [new Target('someTarget')])),
467467
];
468468
$this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0));

src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
use Symfony\Component\DependencyInjection\Tests\Fixtures\WitherStaticReturnType;
6060
use Symfony\Component\DependencyInjection\TypedReference;
6161
use Symfony\Component\ExpressionLanguage\Expression;
62+
use Symfony\Component\VarExporter\LazyObjectInterface;
6263

6364
require_once __DIR__.'/../Fixtures/includes/autowiring_classes.php';
6465
require_once __DIR__.'/../Fixtures/includes/classes.php';
@@ -1697,7 +1698,7 @@ public function testAutowireClosure()
16971698
->setFactory(['Closure', 'fromCallable'])
16981699
->setArguments(['var_dump'])
16991700
->setPublic('true');
1700-
$container->register('bar', LazyConsumer::class)
1701+
$container->register('bar', LazyClosureConsumer::class)
17011702
->setPublic('true')
17021703
->setAutowired(true);
17031704
$container->compile();
@@ -1710,7 +1711,7 @@ public function testAutowireClosure()
17101711
$container = new \Symfony_DI_PhpDumper_Test_Autowire_Closure();
17111712

17121713
$this->assertInstanceOf(Foo::class, $container->get('foo'));
1713-
$this->assertInstanceOf(LazyConsumer::class, $bar = $container->get('bar'));
1714+
$this->assertInstanceOf(LazyClosureConsumer::class, $bar = $container->get('bar'));
17141715
$this->assertInstanceOf(\Closure::class, $bar->foo);
17151716
$this->assertInstanceOf(\Closure::class, $bar->baz);
17161717
$this->assertInstanceOf(\Closure::class, $bar->buz);
@@ -1745,6 +1746,29 @@ public function testLazyClosure()
17451746
$this->assertSame(1 + $cloned, Foo::$counter);
17461747
$this->assertSame(1, (new \ReflectionFunction($container->get('closure')))->getNumberOfParameters());
17471748
}
1749+
1750+
public function testLazyAutowireAttribute()
1751+
{
1752+
$container = new ContainerBuilder();
1753+
$container->register('foo', Foo::class)
1754+
->setPublic('true');
1755+
$container->setAlias(Foo::class, 'foo');
1756+
$container->register('bar', LazyServiceConsumer::class)
1757+
->setPublic('true')
1758+
->setAutowired(true);
1759+
$container->compile();
1760+
$dumper = new PhpDumper($container);
1761+
1762+
$this->assertStringEqualsFile(self::$fixturesPath.'/php/lazy_autowire_attribute.php', $dumper->dump(['class' => 'Symfony_DI_PhpDumper_Test_Lazy_Autowire_Attribute']));
1763+
1764+
require self::$fixturesPath.'/php/lazy_autowire_attribute.php';
1765+
1766+
$container = new \Symfony_DI_PhpDumper_Test_Lazy_Autowire_Attribute();
1767+
1768+
$this->assertInstanceOf(Foo::class, $container->get('bar')->foo);
1769+
$this->assertInstanceOf(LazyObjectInterface::class, $container->get('bar')->foo);
1770+
$this->assertSame($container->get('foo'), $container->get('bar')->foo->initializeLazyObject());
1771+
}
17481772
}
17491773

17501774
class Rot13EnvVarProcessor implements EnvVarProcessorInterface
@@ -1771,7 +1795,7 @@ public function __construct(\stdClass $a, \stdClass $b)
17711795
}
17721796
}
17731797

1774-
class LazyConsumer
1798+
class LazyClosureConsumer
17751799
{
17761800
public function __construct(
17771801
#[AutowireServiceClosure('foo')]
@@ -1783,3 +1807,12 @@ public function __construct(
17831807
) {
17841808
}
17851809
}
1810+
1811+
class LazyServiceConsumer
1812+
{
1813+
public function __construct(
1814+
#[Autowire(lazy: true)]
1815+
public Foo $foo,
1816+
) {
1817+
}
1818+
}

src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/autowire_closure.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ public function isCompiled(): bool
4343
/**
4444
* Gets the public 'bar' shared autowired service.
4545
*
46-
* @return \Symfony\Component\DependencyInjection\Tests\Dumper\LazyConsumer
46+
* @return \Symfony\Component\DependencyInjection\Tests\Dumper\LazyClosureConsumer
4747
*/
4848
protected static function getBarService($container)
4949
{
5050
$containerRef = $container->ref;
5151

52-
return $container->services['bar'] = new \Symfony\Component\DependencyInjection\Tests\Dumper\LazyConsumer(#[\Closure(name: 'foo', class: 'Symfony\\Component\\DependencyInjection\\Tests\\Compiler\\Foo')] function () use ($containerRef) {
52+
return $container->services['bar'] = new \Symfony\Component\DependencyInjection\Tests\Dumper\LazyClosureConsumer(#[\Closure(name: 'foo', class: 'Symfony\\Component\\DependencyInjection\\Tests\\Compiler\\Foo')] function () use ($containerRef) {
5353
$container = $containerRef->get();
5454

5555
return ($container->services['foo'] ??= new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo());
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
use Symfony\Component\DependencyInjection\Argument\RewindableGenerator;
4+
use Symfony\Component\DependencyInjection\ContainerInterface;
5+
use Symfony\Component\DependencyInjection\Container;
6+
use Symfony\Component\DependencyInjection\Exception\LogicException;
7+
use Symfony\Component\DependencyInjection\Exception\ParameterNotFoundException;
8+
use Symfony\Component\DependencyInjection\Exception\RuntimeException;
9+
use Symfony\Component\DependencyInjection\ParameterBag\FrozenParameterBag;
10+
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
11+
12+
/**
13+
* @internal This class has been auto-generated by the Symfony Dependency Injection Component.
14+
*/
15+
class Symfony_DI_PhpDumper_Test_Lazy_Autowire_Attribute extends Container
16+
{
17+
protected $parameters = [];
18+
protected readonly \WeakReference $ref;
19+
20+
public function __construct()
21+
{
22+
$this->ref = \WeakReference::create($this);
23+
$this->services = $this->privates = [];
24+
$this->methodMap = [
25+
'bar' => 'getBarService',
26+
'foo' => 'getFooService',
27+
];
28+
29+
$this->aliases = [];
30+
}
31+
32+
public function compile(): void
33+
{
34+
throw new LogicException('You cannot compile a dumped container that was already compiled.');
35+
}
36+
37+
public function isCompiled(): bool
38+
{
39+
return true;
40+
}
41+
42+
public function getRemovedIds(): array
43+
{
44+
return [
45+
'.lazy.Symfony\\Component\\DependencyInjection\\Tests\\Compiler\\Foo' => true,
46+
'Symfony\\Component\\DependencyInjection\\Tests\\Compiler\\Foo' => true,
47+
];
48+
}
49+
50+
protected function createProxy($class, \Closure $factory)
51+
{
52+
return $factory();
53+
}
54+
55+
/**
56+
* Gets the public 'bar' shared autowired service.
57+
*
58+
* @return \Symfony\Component\DependencyInjection\Tests\Dumper\LazyServiceConsumer
59+
*/
60+
protected static function getBarService($container)
61+
{
62+
return $container->services['bar'] = new \Symfony\Component\DependencyInjection\Tests\Dumper\LazyServiceConsumer(($container->privates['.lazy.Symfony\\Component\\DependencyInjection\\Tests\\Compiler\\Foo'] ?? self::getFoo2Service($container)));
63+
}
64+
65+
/**
66+
* Gets the public 'foo' shared service.
67+
*
68+
* @return \Symfony\Component\DependencyInjection\Tests\Compiler\Foo
69+
*/
70+
protected static function getFooService($container)
71+
{
72+
return $container->services['foo'] = new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo();
73+
}
74+
75+
/**
76+
* Gets the private '.lazy.Symfony\Component\DependencyInjection\Tests\Compiler\Foo' shared service.
77+
*
78+
* @return \Symfony\Component\DependencyInjection\Tests\Compiler\Foo
79+
*/
80+
protected static function getFoo2Service($container, $lazyLoad = true)
81+
{
82+
$containerRef = $container->ref;
83+
84+
if (true === $lazyLoad) {
85+
return $container->privates['.lazy.Symfony\\Component\\DependencyInjection\\Tests\\Compiler\\Foo'] = $container->createProxy('FooProxy9f41ec7', static fn () => \FooProxy9f41ec7::createLazyProxy(static fn () => self::getFoo2Service($containerRef->get(), false)));
86+
}
87+
88+
return ($container->services['foo'] ??= new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo());
89+
}
90+
}
91+
92+
class FooProxy9f41ec7 extends \Symfony\Component\DependencyInjection\Tests\Compiler\Foo implements \Symfony\Component\VarExporter\LazyObjectInterface
93+
{
94+
use \Symfony\Component\VarExporter\LazyProxyTrait;
95+
96+
private const LAZY_OBJECT_PROPERTY_SCOPES = [];
97+
}
98+
99+
// Help opcache.preload discover always-needed symbols
100+
class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class);
101+
class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectRegistry::class);
102+
class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectState::class);

0 commit comments

Comments
 (0)