Problem/Motivation
To wire services to a plugin, developers must implement ContainerFactoryPluginInterface and a custom create() method that pulls services from the container and calls the plugin constructor. This is largely boilerplate code and must be written for each plugin implementation.
Using PHP 8 attributes, we can tag constructor parameters directly and allow the factory class to discover and inject the required services, and drop the create() method entirely.
This also has the advantage of placing the service name directly next to the parameter where it is used, instead of having to keep the create() method and constructor signature in sync in two different places.
Steps to reproduce
Proposed resolution
Add support to ContainerFactory for Symfony's #[Autowire] attribute.
Remaining tasks
Determine if there are any more edge cases to cover.
User interface changes
API changes
Plugins can now wire services by specifying an #[Autowire] attribute on the each constructor parameter, instead of writing a separate create() method.
Data model changes
Release notes snippet
| Comment | File | Size | Author |
|---|---|---|---|
| #24 | 3294266-24.patch | 2.98 KB | longwave |
Issue fork drupal-3294266
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
longwaveAs an example, this code
can be reduced to something like
With PHP 8 constructor property promotion this can be reduced even further, but that is for a separate issue.
Comment #3
longwaveRough prototype which converts two core plugins. Backward compatibility will be the tricky part here.
The name ContainerAutowiredPluginInterface is in the hope that one day we can drop the attribute in most cases and still inject the correct service, if we complete #3049525: Enable service autowiring by adding interface aliases to core service definitions
Comment #4
longwaveComment #5
longwaveComment #6
longwaveThere is also an argument for using https://github.com/symfony/dependency-injection/blob/6.2/Attribute/Autow... as the attribute instead of creating our own, as we might want to use Symfony's attribute for autowiring services.
Comment #8
longwaveRefactored to not require an interface and borrow Symfony's autowire attribute. Added some tests for various cases including autowired subclasses inheriting from non-autowired parents and vice versa.
Comment #9
longwaveComment #10
longwaveComment #11
hawkeye.twolfAre plugin constructors constructors still considered internal in Drupal's BC policy? This post by @larowlan outlines why it's better to use the
create()method (which is protected from BC changes) to inject dependencies and not risk encountering breaking changes to the parent constructor signature.Is it possible to use autowiring with the
create()method instead of (or in addition to) the constructor?Comment #12
longwaveSymfony also supports autowired setter injection, that is definitely something we could consider supporting as well, if that helps - but the code as it stands should support both the old create() method and autowiring, even in subclasses.
Comment #14
larowlanLooks like the last re-roll was off 10.0.x - rebased it off 10.1.x with --onto
Fixed the ContainerFactoryTest
Comment #15
andypostthanks! otherwise RTBC as it needs to notify module developers
Comment #16
larowlanI was getting some fails locally on tests in entity hierarchy around condition plugins and then realised I still had this branch checked out.
The patch breaks this scenario
Plugin that implements ContainerFactoryPluginInterface that has a trait that pulls in the ::create and ::__construct methods.
See https://git.drupalcode.org/project/entity_hierarchy/-/blob/3.x/modules/e... for an example
So we also need tests for that
Comment #17
longwave@larowlan I added a test that is similar to your trait setup but it passed first time, so not sure what I have done wrong - any ideas?
Comment #18
larowlanI'll see if I can still replicate it - might have been a PHP version issue?
Comment #19
longwaveFor full backward compatibility we might need to provide an autowiring trait, which perhaps means we can simplify some of this.
In the long run we probably want to drop the create method here and use autowiring instead, but then we are no longer implementing ContainerFactoryPluginInterface, and any subclasses will no longer have their
create()method called.So maybe the answer is to autowire the constructor, and use a trait to replace
create()that handles the autowiring? Then any subclasses will work with no changes - we could also find a way to notify downstream users that they should switch to autowiring, or maybe we just live with the trait forever until we deprecate ContainerFactoryPluginInterface entirely?Comment #21
smustgrave commentedSounds like this should be NW for the trait mentioned in #19?
Comment #23
bradjones1Marked #2053415: Allow typed data plugins to receive injected dependencies as a duplicate as well. Would be great to make some traction on this by answering the questions raised in #19, or perhaps this just needs an IS update to reflect the current status after the recent MR changes.
Comment #24
longwave@duadua Some great ideas there. I especially like the idea of doing the reflection at discovery time instead of runtime, though that needs a bit more thought on how to architect and store the necessary data.
Following these issues:
#3049525: Enable service autowiring by adding interface aliases to core service definitions
#3325557: Enable more service autowiring by adding interface aliases to core modules
it is possible to infer the correct service in many cases without needing the #[Autowire] attribute. Therefore we can implement a trait that autowires based on the type where possible, or the attribute where it isn't possible, and this seems fairly trivial. I don't think we have to worry about inheritance even? Subclassed plugins will continue to provide their own
create()method, parent or child classes can switch to the trait any time, as far as I can see - this might need a bit of testing though.Proof of concept patch attached.
Comment #25
smustgrave commentedIf a new approach is being taken could the IS be updated?
Will new approach still require the change record? Leaving tag just in case.
Comment #26
joachim commentedCan this be expanded to all classes that have the container factory pattern -- forms and controllers IIRC.
Comment #27
longwaveYes, but probably not in this issue; I picked plugins first as I was getting annoyed keeping
create()methods in sync with the constructor but those are valid candidates as well.Comment #28
joachim commentedForms have exactly the same pattern of create() and __construct(), just use Drupal\Core\DependencyInjection\ContainerInjectionInterface instead.
Comment #29
longwaveBut the plugin factory method and constructors have three additional arguments -
array $configuration, $plugin_id, $plugin_definition- so I don't think we can use the same code/trait.Comment #30
acbramley commentedWe have the controller issue
Comment #31
donquixote commentedSo, the trait in the patch does the job. +1.
Is there any benefit of doing it in ContainerFactory instead of using the trait?
Comment #32
donquixote commentedActually, migrate plugins get an additional parameter passed to the ::create() method.
In MigratePluginManager:
The positional mapping with 3 parameters would lose this 4th parameter.
But technically there is no guarantee that calling code will always pass the `$migration` parameter.
The 4th parameter is optional, to comply with LSP.
I think such cases need a custom trait with 4 parameters instead of 3.
The trait can be chosen per class, according to the needs of the constructor.
E.g. some migrate plugins might not need the `$migration`, then they can use the regular trait.
Comment #33
joachim commented> But the plugin factory method and constructors have three additional arguments - array $configuration, $plugin_id, $plugin_definition
Field formatter plugins have more base constructor arguments too. I know ALL about this problem from Module Builder.... :/
Comment #34
dpiCreated #3452852: Add create() factory method with autowired parameters to PluginBase, as an interim DX measure for all existing (legacy) plugins.
Comment #35
elc commentedAs a step forward, would it be better to push the AutowirePluginTrait [1] in the lead up to D11, as it is so alike the services Drupal\Core\DependencyInjection\AutowireTrait ?
[1]
Drupal\Core\DependencyInjection\AutowirePluginTrait#3452852: Add create() factory method with autowired parameters to PluginBase
[2]
Drupal\Core\DependencyInjection\AutowireTraitAutowireTrait allows ContainerInjectionInterface classes to be autowired
This would seem to be in keeping with the current thrust of development. There would also need to be a specialist Trait for the migration plugins which require 4 parameters.
What would be the next steps needed? Core tests: one that shows it working the old way, and one that shows it working the new?
Comment #36
acbramley commentedAgree that the Trait would be awesome
Comment #37
longwaveUrgh, the field formatters case is pretty horrible.
Shouldn't that extraction work be done in the constructor instead? But changing all existing constructors and providing BC is going to be no fun at all.
Comment #38
solideogloria commentedAgreed. Just pass the
$configurationto the constructor.Comment #39
kim.pepperI did a quick count and there are 92 sub-classes of FormatterBase in core alone.
Comment #40
catchI was a bit worried about extra attribute parsing etc. but looking at the MR, it only happens when we need want to instantiate the plugin anyway so any overhead should be negligible.
Comment #41
bradjones1Drive-by idea: Is the autowiring logic in Symfony able to be extrapolated/re-used here to basically allow for autowiring similar to services? It's the same thing... except these aren't first-class services. Or am I way off?
Comment #43
longwaveFollowing #40 I've had an idea for a while that I've now managed to test out, and it's looking quite promising. We can do the reflection at discovery time and store the set of required services for plugins in the plugin definition, then use that metadata in the plugin factory to skip the create() method. MR!11031 has a prototype of this. We do instantiate quite a few plugins on some page loads so storing the metadata in advance and avoiding reflection at runtime would be good.
I think with this technique we can work around the varying requirements of constructors of different plugin types by passing in more metadata into the discovery and/or extending the factory.
Comment #44
longwaveWe can't just delete
::create()straight away, because a subclass might be calling it. But for plugins that can be autowired, what about flagging::create()as#[\Deprecated](or a similar custom attribute)? This would indicate both that we intend to delete it in the next major (and so we don't need to notify directly that autowiring is available) but any subclasses *should* be notified because it will break in future? Subclasses can then also implement the attribute themselves, or just convert to autowiring.Comment #45
godotislateThis was mentioned in #11, but it's been common practice to use
::create()in plugin classes that extend other plugin classes, to avoid issues if the parent constructor's signature changes. I think before deprecating::create(), at least the suggestion from #12 to accommodate autowired setter injection (presumably with the use of the the\Symfony\Contracts\Service\Attribute\Requiredatribute) should be done in a follow up.Comment #46
donquixote commentedFor the inheritance problem:
I just found out that we can put attributes on variadic parameters :)
If we could create this attribute or find out that it already exists somewhere...
Comment #47
mxh commentedRunning the reflection scan on possibly thousands of plugin definitions where only a fraction of them make use of it looks like a disproportionate resource overhead. Why not handling this similar to Annotations -> Attributes transition? For sure once autowiring is possible, more and more plugin implementations will adapt, but it's a matter of months if not years to come. I'd introduce an Autowire interface for plugins similar as ContainerFactoryPluginInterface to quickly identify which plugin is making use of it. For comparison, services also need an additional
autowire: truedeclaration in order to make use of it. And there may be way more plugin definitions than service definitions around.Comment #48
longwaveFixed some more tests. But we have some interesting cases to solve:
ContentModerationHooks::actionInfoAlter()swaps the class of two action plugins. But these have different constructor signatures and factory methods, so we need to ideally parse the constructor after alter hooks have run. But maybe someone also wants to alter the autowiring metadata?NavigationMenuBlockextendsSystemMenuBlockbut passes innavigation.menu_treewhich is an alternative implementation ofMenuLinkTreeInterface. This must be explicitly declared; I think some way of opting in plugins is the only way to solve this.Perhaps we need to add an
autowireproperty toDrupal\Component\Plugin\Attributeand all subclasses? This doesn't solve point 1, however.Comment #49
longwaveI think to solve #48.1 we need to drop the decorator - instead move the autowiring discovery to the factory, and call it as a kind of factory-preparation step that follows discovery and alter.
Comment #50
longwaveLooking for reviews/opinions on whether adding
autowireas a boolean property to every plugin definition is the correct solution here. This lets plugins opt in individually which solves #47, and I now think may be required as per #48.2, because we can't guarantee to infer correctly in all cases from interfaces alone.If we do do this I also think we have to keep the
create()methods around for now because there might be subclasses callingparent::create(). There is an attempt in the MR at a BC layer for this, because I think we can and should start removing the interface when the plugins are autowired, but the interface might not be explicitly added to subclasses.Comment #51
joachim commented> Looking for reviews/opinions on whether adding autowire as a boolean property to every plugin definition is the correct solution here.
Dumping it in the plugin definition seems wrong, when it's something that's only needed internally, and not by any code using the plugin definition.
Comment #52
catch1. Could we put it in its own attribute per #51?
2. Could we flip the default in a future major release? Seems worth a spin-off issue even if we eventually decide not to do that.
Comment #53
longwaveWe put the class name in the plugin definition, it's not too dissimilar from that - something that is needed in order to construct the plugin? To me the plugin definition is any metadata that's required before the plugin needs to be constructed, and the autowire flag fits into that category. We also need to store the set of services somewhere - if not on the plugin definition, then where?
It would be nice to reuse the existing
#[Autowire]attribute from Symfony on the class, but that's only allowed on parameters:If we do go this route this would mean having a new name -
#[AutowiredPlugin]?We can flip the default either way, either globally or on a per-definition basis I think. But we haven't done that for services, I don't know if we would do it here or not.
Comment #54
smustgrave commentedTried to the resolve the threads I could.
Maybe the CR well help clear up the others?
Comment #55
longwave#3452852: Add create() factory method with autowired parameters to PluginBase landed, which implements this at runtime instead of discovery time. I think we can continue here with adding the reflection step at discovery time and using that data if it's available instead of needing reflection at runtime.
Comment #56
godotislateThere's work started in #3544405: [PP-1] Replace SearchBlock service properties with service closures to use closures in order to inject services lazily into plugin classes (the search block, in this case). I wonder if it makes sense to expand on #3452852: Add create() factory method with autowired parameters to PluginBase to accommodate the use of the AutowireServiceClosure attribute as well. In which case, does it make sense to do this first and do the reflection at discovery time, and then add autowiring for service closures? Or can the two tasks be done independently?
Note that to use service closures in plugin classes #3544994: Make service closures serializable in classes using DependencySerializationTrait will need to land as well.
Comment #57
longwaveI think they can be done independently? What I'm kinda proposing here is splitting up the reflection and instantiation parts so they are done at different times, but as long as both parts understand about closures, it doesn't matter whether the reflection is done at discovery or runtime.
Comment #58
longwaveRetitling this as I think we can narrow the scope a bit.
Comment #60
longwaveNeeds tests, but MR!13493 should improve performance by handling reflection at discovery time for plugins autowired via
PluginBase::create().Comment #61
longwaveCan't reproduce those three fails locally, all those tests pass for me.
Comment #62
berdirI think we'll definitely need to carefully compare both build and runtime. This also adds to the plugin definition cache size and it means more things to unserialize, for all plugin definitions on all requests. I've seen huge block plugin caches, so this might cost us more than we gain since it's for classes that we'll load and instantiate anyway. If you have lots of content_blocks or lots of menus, then we'd add those definitions many times.
Comment #63
longwaveMaybe we need a separate map object where we store the arguments once against the class name. Which when I write it out, sounds quite like a container!
Comment #64
kim.pepperMy 2 cents... if we are injecting services at discovery time, what advantage does this have over regular container services?
Comment #65
bradjones1Re: #62, At the risk of sounding like a broken record about my favorite (kinda stalled) core feature... if we use JSON data columns for this kind of caching we can avoid serialization entirely and index on whatever paths need rapid access? #3343634: Add "json" as core data type to Schema and Database API
Comment #66
dcam commentedThe issue summary still talks about desire to add autowiring to classes, which landed some time ago and is mentioned in #55. Since the issue's focus has changed the summary needs to be updated. MRs that are no longer relevant need to be closed. I'm also adding the Needs Tests tag per #60.