Problem/Motivation
Calling \Drupal::service($id) does not return a type hint for PHPStan validation and IDE completion. Traditionally this was because $id was a service name, and we had no way of knowing what the class / interface was.
Now we provide service aliases everywhere and can call \Drupal::service($id) with a class string. For example:
$configFactory = \Drupal::service(ConfigFactoryInterface::class)
In this case we know that $configFactory will be an implementation of \Drupal\Core\Config\ConfigFactoryInterface. However there is no way for PHPStan to know that.
We can provide PHPStan annotations to infer the return type when the class string passed in as an argument. We should keep bc for when it is not a class string, but a regular service name.
Proposed resolution
We can use Generics and conditional return types to acheive this.
For example:
<?php
/**
* Retrieves a service from the container.
*
* @param class-string<T>|string $id
* The ID of the service to retrieve.
*
* @template T of object
*
* @return ($id is class-string<T> ? T : object)
* The specified service.
*/
public static function service(string $id): object {}
?>Remaining tasks
User interface changes
Introduced terminology
API changes
Data model changes
Release notes snippet
Original Issue Summary
Currently we still need to call `Drupal::service()` or `$container->get(..)` explicitly in some places.
Especially in tests, we won't be able to remove these calls.
Currently these have the problem that static analysis (IDE, phpstan) has no idea what the return type would be.
(unless they use some kind of magic plugin that reads the services.yml files)
Even if we use the interface name alias for the service id, static analysis cannot know for sure that the return value will be that.
We either need to put a `/** @var ... */` doc above the variable, or we don't get static anasysis.
Also, this does not work well with method chaining, e.g. `Drupal::service('foo_service')->foo()`, here static analysis has no way to know if the ->foo() call is valid.
There is also ClassResolverInterface, and Drupal::classResolver(), but these are useless for static analysis, because there is no guarantee about the return type.
Also, Drupal::classResolver() has a conditional return type, which makes it harder to document.
Changing these would be a BC break.
Provide alternatives to Drupal::service() and $container->get().
The first parameter is a class or interface name.
The second, optional, parameter can specify a service id, if that differs from the class or interface name from the first parameter.
The return type is documented to be the same as the class name provided in the first parameter, using `@template T of object`, and `@param class-string $class` and `@return object&T` or just `@return T`.
E.g.
use Drupal\Component\Datetime\TimeInterface;
$time = Drupal::serviceByClass(TimeInterface::class);
assert($time instanceof TimeInterface);
// This would be redundant, but works.
$time = Drupal::serviceByClass(TimeInterface::class, 'datetime.time');
assert($time instanceof TimeInterface);
// The method will fail if the service instance does not have the expected type.
$this->expectException(UnexpectedServiceClassException::class);
Drupal::serviceByClass(TimeInterface::class, 'file_system');
The same can be done as an object method, possibly as part of Drupal's ContainerInterface, or as a separate service.
| Comment | File | Size | Author |
|---|---|---|---|
| #19 | 3490713-nr-bot.txt | 1.26 KB | needs-review-queue-bot |
Issue fork drupal-3490713
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
donquixote commentedComment #4
chi commentedFor PhpStorm you can generate meta information about services.
Psalm also reads it. Not sure about PhpStan. I suppose phpstan-drupal can help on this.
Comment #6
donquixote commentedThanks!
In the past I tried the symfony plugin for phpstorm which was also supposed to do this.
But it slowed everything down.
I think what I am proposing here is nicer, and more universally available.
Comment #7
donquixote commentedI would like to add tests, but not sure where they would go.
Comment #8
donquixote commentedFor $container->getByClass() this is going to be more difficult, if we want to maintain full BC.
I was going to introduce a new interface ServiceByClassInterface and ConvenientContainerInterface, which would be implemented by our container classes.
But then in every place we need to check if the container we have is actually implementing that interface.
If instead we add the method directly to our ContainerInterface, any custom implementations will break which don't have this method.
----
Alternatively we could introduce a function like this:
so the same as above but we pass the $container object, so it is cleaner.
or
or we have something that returns a closure.
But currently a lot of the $container->get() calls should rather be replaced by some kind of autowire solution.
Comment #9
quietone commentedComment #10
donquixote commentedSeems we are suffering this one, #3468830: [random test failure] BlockCacheTest::testCachePermissions()
And also another random test failure which happened earlier.
Comment #11
donquixote commentedComment #12
donquixote commentedChanging issue title to be more findable, hopefully
Comment #13
james.williamsIf the service registry allows swapping/overriding/decorating the class for a service, how does that interact with this? If you pass a decorated class to serviceByClass(), should it return the decorator, or the decorated class? If you pass the class of a service that has been swapped for a different class, which do you get? I'm not convinced this is a good idea, I'm concerned it could bring confusing DX for the sake of helping tooling rather than anything functional.
Comment #14
bradjones1Re: #13 - I can at least answer the question of, what service do you get? You get the decorated service. This is as it works now, at least if the service's name is the same as its original class.
The bigger issue is that what you really want is a response containing a predictable interface. That's harder to typehint. If we could solve for that edge case (or it can be explained a bit better what you get in various circumstances, e.g. a truth table) and it works with IDE completion, this would be amazing.
Comment #15
james.williamsOK, that makes sense - but then makes the method name rather confusing. Getting a 'service by class' which isn't even the class you asked for, is straight-up confusing. So maybe a name of Drupal::serviceByInterface() would be better? Or, since many services are already aliased to an interface name, could something like phpstan-drupal be made to understand that \Drupal::service(MyInterface::class) returns an instance of MyInterface to avoid the need for this at all?
Making changes for the sake of the tooling still feels a bit like the 'tail wagging the dog'.
Comment #16
bradjones1In my practice, I definitely find myself wanting something like this but also my IDE knows what to expect once I put in an
assert()on the next line. This has the benefit, also, of validating that I am getting the service I expect during tests and local development. So personally this is in the lower-priority bucket as there are other Drupalisms in the service container I'd like to tackle first... BUT if this did all the things out of the box as we'd expect, then that's great. But I agree if the edge cases are just as tricky as the status quo, there's no benefit.Comment #17
bradjones1To clarify what I mean in the prior comment - I use assert() in particular to check the order of services when I have multiple layers of decoration. So niche case but it was unclear the way I phrased it.
Comment #18
donquixote commentedIndeed Drupal::serviceByInterface() would be a more accurate.
Basically you use the type that you would also use as a parameter type in a constructor that expects that service.
So it is the equivalent to autowire.
The reason I named it serviceByClass() in the MR is that:
I tend to do either assert(* instanceof *) or an inline /** @var */ doc.
The problem with both of these is that they are more verbose, and they require a local variable, where otherwise I could use method chaining like Drupal::service(Abc::class)->foo();.
Ok there are two different goals / cases here:
Comment #19
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #20
longwaveThanks to the autowiring work, given we can already do
is there something clever we can do with the docblock of
\Drupal::service()so PHPStan can infer the type if you pass an interface name to the existing method, without having to add some new API? Or can we add a custom PHPStan extension that helps it map service names to classes?Comment #21
mstrelan commentedI was about to comment the same as #20. I think it's worth exploring.
In https://phpstan.org/blog/phpstan-1-6-0-with-conditional-return-types there is this example:
I wonder if we can do this:
Comment #22
kim.pepper(edit: posted at the same time as #21)
Could we merge this docblock with the existing \Drupal::service() docblock somehow?
Comment #24
kim.pepperAdded a new MR that just adds the class-string typehints to
\Drupal::service().Comment #25
kim.pepperAdded the conditional return type as per #21
Comment #26
kim.pepperTesting in PHPStorm this seems to work well.
Comment #27
mstrelan commentedAgreed this works well in phpstorm. I also ran phpstan on max level before and after and there was no difference in the total number of violations, not sure what to make of that.
I notice this now closely matches
\Symfony\Component\DependencyInjection\ContainerInterface::get, except that includes conditions for the$invalidBehaviorparam that we don't need to worry about here.If we're happy with this change of directions then this needs an issue summary update, otherwise RTBC for me.
Comment #28
kim.pepperUpdated the title and IS to reflect the new approach.
Comment #31
godotislateI can also confirm this works for me in PHPStorm.
I think the IS might need updating under "Proposed resolution," but otherwise this looks good to me. Concern about BC was addressed.
Comment #33
kim.pepperUpdated IS
Comment #34
berdir> I also ran phpstan on max level before and after and there was no difference in the total number of violations, not sure what to make of that.
I think a lot of work went into the phpstan integration to actually make it understand those services, whether that's a class/interface or any other string. So I'm fairly certain that the claim in the issue summary ("However there is no way for PHPStan to know that.") is actually wrong.
The same is not true for phpstorm and possibly other tools though. It doesn't work as well there, even with the symfony plugin and so on.
FWIW, this did a considerable shift in direction in the last day, I do want to point out that @donquixote explicitly argued that it should be separate in earlier discussions here. I don't agree with that option, and I think we shouldn't add more API endpoints for non-injected code when we are now at a point with autowire+autodiscovery+relaxed doc standards that it's actually becoming easier to do DI than not (or maybe restricted to tests where we don't want to use DI). I'm just saying that we should consider that opinion/direction change.
Comment #35
longwaveOne reason that PHPStan doesn't see any difference is that we aren't calling
\Drupal::service()with a class string very often - for historical reasons most tests will use the custom service name, and this change doesn't help with that.Comment #36
mstrelan commentedThat's a good point in #35. There is actually a slight drop in numbers after a recent commit adding
<T>to the conditional return.Was needs work intentional? Maybe back to needs review for #34?
Comment #37
longwaveDidn't mean to change this to NW. Also tested in PHPStorm and this works well.
Comment #38
donquixote commentedI think there was some reason why I did not do this originally.
But it does seem to work correctly.
https://phpstan.org/r/4b39b30f-7165-44db-b464-1b9b1312c10f
So I support it!
Comment #39
donquixote commentedSo one thing we cannot do here is to add the assert instanceof as part of the ::service() method.
Comment #40
longwaveGiven nobody should be subclassing
\Drupalthen I think this is safe to backport to 10.6.x. We could also backport the docs only part to 11.2.x/10.5.x but at this stage I don't think it's worth the effort.Committed and pushed 76a93eece07 to 11.x and a55b4e7d6f5 to 11.3.x and 451faa86060 to 10.6.x. Thanks!
Comment #49
dinazaur commentedIf you use PHPStorm + Symfony plugin and services autocompletion lags, its because of this php doc. Just delete the doc locally anyway, it doesn't do anything if you use Symfony plugin.