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.

CommentFileSizeAuthor
#19 3490713-nr-bot.txt1.26 KBneeds-review-queue-bot

Issue fork drupal-3490713

Command icon 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

donquixote created an issue. See original summary.

donquixote’s picture

Issue summary: View changes

chi’s picture

unless they use some kind of magic plugin that reads the services.yml files

For PhpStorm you can generate meta information about services.

drush gen phpstorm-meta

Psalm also reads it. Not sure about PhpStan. I suppose phpstan-drupal can help on this.

donquixote’s picture

For PhpStorm you can generate meta information about services.

Thanks!
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.

donquixote’s picture

I would like to add tests, but not sure where they would go.

donquixote’s picture

For $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:

$time = service(TimeInterface::class, $container);
assert($time instanceof TimeInterface);

so the same as above but we pass the $container object, so it is cleaner.

or

$time = ServiceFromClass::create($container)->getByClass(TimeInterface::class);
assert($time instanceof TimeInterface);

or we have something that returns a closure.

$time = resolver($container)(TimeInterface::class);
assert($time instanceof TimeInterface);

$resolve = resolver($container);
assert($resolve(TimeInterface::class) instanceof TimeInterface);

But currently a lot of the $container->get() calls should rather be replaced by some kind of autowire solution.

quietone’s picture

Version: 11.1.x-dev » 11.x-dev
donquixote’s picture

Seems we are suffering this one, #3468830: [random test failure] BlockCacheTest::testCachePermissions()
And also another random test failure which happened earlier.

donquixote’s picture

Status: Active » Needs review
donquixote’s picture

Title: Alternative to Drupal::service() or $container->get() with return type promise » Introduce Drupal::serviceByClass() as alternative to Drupal::service(), where phpstan predicts the return type

Changing issue title to be more findable, hopefully

james.williams’s picture

If 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.

bradjones1’s picture

Re: #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.

james.williams’s picture

OK, 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'.

bradjones1’s picture

In 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.

bradjones1’s picture

To 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.

donquixote’s picture

The bigger issue is that what you really want is a response containing a predictable interface.

So maybe a name of Drupal::serviceByInterface() would be better?

Indeed 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:

  • Some services don't have an interface. Especially in custom code or in early versions of a module this might be the case. If constructor parameter types use the class name, then any decorators also need to extend that.
  • The term 'class' is used as a catch-all term in many places, where traits and interfaces are also included. E.g. "ReflectionClass" (php native), "class-string" (phpstan), "ClassLoader" (composer), etc.

In my practice, I definitely find myself wanting something like this but also my IDE knows wha to expect once I put in an assert() on the next line.

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();.

This has the benefit, also, of validating that I am getting the service I expect during tests and local development.

I use assert() in particular to check the order of services when I have multiple layers of decoration

Ok there are two different goals / cases here:

  • In a test, I may want to assert the exact class of the service, to make sure my decorators work correctly. In that case I would use $this->assertSame(MyClass::class, get_class($service)); rather than native assert(* instanceof *) or PhpUnit ->assertInstanceOf().
  • In my regular code, I would only ever assert($service instanceof SomethingInterface), because other modules might want to add further decorators. This verifies that the DI is not completely giving me the wrong service, but it does not verify that specific decorators are applied.
needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new1.26 KB

The 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.

longwave’s picture

Thanks to the autowiring work, given we can already do

> \Drupal::service(\Drupal\Core\Entity\EntityTypeManagerInterface::class);
= Drupal\Core\Entity\EntityTypeManager {#4185}

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?

mstrelan’s picture

I 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:

/**
 * @template T of object
 * @param class-string<T> $class
 * @return ($throw is true ? T : T|null)
 */
function getService(string $class, bool $throw = true): ?object

I wonder if we can do this:

/**
 * @template T of object
 * @param class-string<T>|string $id
 * @return ($id is class-string ? T : object)
 */
public static function service($service)
kim.pepper’s picture

(edit: posted at the same time as #21)

Could we merge this docblock with the existing \Drupal::service() docblock somehow?

*  @template T of object
*   
*  @param class-string<T> $class
*
*   @return object&T
*       A service that is an instance of $class.

kim.pepper’s picture

Status: Needs work » Needs review

Added a new MR that just adds the class-string typehints to \Drupal::service().

kim.pepper’s picture

Added the conditional return type as per #21

kim.pepper’s picture

Testing in PHPStorm this seems to work well.

mstrelan’s picture

Agreed 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 $invalidBehavior param that we don't need to worry about here.

    /**
     * @template C of object
     * @template B of self::*_REFERENCE
     *
     * @param string|class-string<C> $id
     * @param B                      $invalidBehavior
     *
     * @return ($id is class-string<C> ? (B is 0|1 ? C|object : C|object|null) : (B is 0|1 ? object : object|null))
     *
     * @throws ServiceCircularReferenceException When a circular reference is detected
     * @throws ServiceNotFoundException          When the service is not defined
     *
     * @see Reference
     */
    public function get(string $id, int $invalidBehavior = self::EXCEPTION_ON_INVALID_REFERENCE): ?object;

If we're happy with this change of directions then this needs an issue summary update, otherwise RTBC for me.

kim.pepper’s picture

Title: Introduce Drupal::serviceByClass() as alternative to Drupal::service(), where phpstan predicts the return type » Return the correct typehint when Drupal::service() is called with a class string
Issue summary: View changes

Updated the title and IS to reflect the new approach.

godotislate changed the visibility of the branch 3490713-alternative-to-container-get to hidden.

godotislate changed the visibility of the branch 3490713-alternative-to-drupalservice-POC-conversions to hidden.

godotislate’s picture

Status: Needs review » Reviewed & tested by the community

I 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.

godotislate changed the visibility of the branch 3490713-alternative-to-drupalservice to hidden.

kim.pepper’s picture

Issue summary: View changes

Updated IS

berdir’s picture

> 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.

longwave’s picture

Status: Reviewed & tested by the community » Needs work

One 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.

mstrelan’s picture

That'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?

longwave’s picture

Status: Needs work » Reviewed & tested by the community

Didn't mean to change this to NW. Also tested in PHPStorm and this works well.

donquixote’s picture

I 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!

donquixote’s picture

I think there was some reason why I did not do this originally.

So one thing we cannot do here is to add the assert instanceof as part of the ::service() method.

longwave’s picture

Version: 11.x-dev » 10.6.x-dev
Status: Reviewed & tested by the community » Fixed

Given nobody should be subclassing \Drupal then 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!

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

  • longwave committed 451faa86 on 10.6.x
    task: #3490713 Return the correct typehint when Drupal::service() is...

  • longwave committed a55b4e7d on 11.3.x
    task: #3490713 Return the correct typehint when Drupal::service() is...

  • longwave committed 76a93eec on 11.x
    task: #3490713 Return the correct typehint when Drupal::service() is...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

dinazaur’s picture

If 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.