Plugins¶
The analyzer types the plugins created by core's plugin managers and the definitions read by
plugins. The checks report unknown plugin ids, plugin managers that never call alterInfo() or
setCacheBackend(), and plugin annotations that still use the context key, which Drupal 9
removed.
Plugin instances¶
$manager->createInstance('id') returns the plugin class for core's attribute-based managers:
blocks, actions, conditions, field types, widgets, formatters, layouts, mail, queue workers, render
elements, typed data, filters, image effects, media sources, REST resources and the other managers
whose constructor names an attribute class. A contrib manager that extends one of them, or a
receiver typed by the manager's interface, is recognized through the class ancestry.
$blockManager->createInstance('page_title_block'); // Drupal\Core\Block\Plugin\Block\PageTitleBlock
$blockManager->createInstance('system_menu_block:main'); // Drupal\system\Plugin\Block\SystemMenuBlock
$blockManager->createInstance('no_such_block'); // Broken, and reported as drupal/unknown-plugin
- A
base:derivativeid resolves through its base plugin, the longest one declared, since a base can hold a colon itself, as core'sentity:save_actiondoes. That is wrong for a deriver that sets aclassper derivative. - On a fallback manager (blocks, entity reference selection and filters), an unknown plain id is typed as the fallback plugin, since that is what runs. A derivative id with an undeclared base keeps the declared type, since the base may come from YAML or a deriver that the index doesn't read.
The plugin index¶
The plugin classes are read from Mago's class metadata: every instantiable class that descends
from PluginInspectionInterface, or from the mail, archiver, config action and language
negotiation method interfaces, which don't extend it, and that has one of those attributes or a
subclass of one. Vendor code counts, and field items reach the index through TypedData.
The id is the attribute's literal id, or the string that a class constant it names holds, as in
id: LanguageNegotiationUrl::METHOD_ID. Two classes that declare the same id cancel each other out.
Contrib still ships legacy docblock annotations such as @Block(id = "…") or @RenderElement("…").
The analyzer reads them from the src/Plugin/**/*.php and src/Element/*.php files of every
extension under the Drupal root, analyzed or in includes, from disk through the cache. An
annotation maps to the attribute of the same short name, and @SearchPlugin and @FormElement are
mapped by hand. The legacy @ContentEntityType, @ConfigEntityType and @EntityType annotations
are read the same way, see The entity type index.
- An analyzed file that has an annotation on disk is read from the text analyzed by Mago, so an edit in an editor buffer that hasn't been saved counts.
- An annotation added to a file that didn't have one when the workers started is read once the file is saved.
- An annotated file outside the Drupal root isn't read.
An annotation doesn't count on an abstract class, in a @code sample, or on a class that also has
the attribute. Two annotated classes that claim one id cancel each other out, as attributed ones do.
Not modeled¶
These keep the declared types:
- the changes that a
hook_*_info_alter()makes to definitions; - the managers whose attribute isn't known to the analyzer;
- plugins discovered from YAML, such as menu links.
Plugin definitions¶
PluginBase documents $pluginDefinition and getPluginDefinition() as
array|PluginDefinitionInterface, so Mago reports every $this->pluginDefinition['label'] as
invalid-array-access for the object half. Arrays are Drupal's default: annotation, attribute and
YAML discovery all produce them, unless the plugin type opts into a definition class.
The analyzer drops that report on a plugin's own definition, read through the property or the getter. It keeps the report:
- on a layout, a Layout Builder section storage or a CKEditor 5 plugin, which have definition objects in core;
- on a class with a discovery attribute whose
get()returns an object; - on the generic bases that every plugin type extends, both
PluginBaseclasses andConfigurablePluginBase, since a plugin of any type may be$thisthere.
$this->pluginDefinition['label']; // no invalid-array-access on a block, local task or filter
$this->getPluginDefinition()['label']; // the same through the getter
$block->getPluginDefinition(); // array<string, mixed> on a BlockPluginInterface
getPluginDefinition() on another plugin returns array<string, mixed> when the type of the
receiver names a plugin type with array definitions: an interface that extends
PluginInspectionInterface, other than the context-aware ones that layouts and section storage
share, or a discovery attribute. That covers a definition held in a variable too. A receiver typed
as PluginInspectionInterface or PluginBase keeps core's union, since any plugin type fits. In a
trait, $this->getPluginDefinition() is an array when every class that uses the trait has array
definitions, so BlockPluginTrait gets one, and ContextAwarePluginTrait, which layouts use too,
keeps the union.
Derivers¶
A deriver gets its base definition documented the same way, so inside a class that implements
DeriverInterface every such report is dropped, whatever the variable is called. A deriver for a
plugin type with definition objects works with them through their methods. A plugin manager's
$definition in processDefinition() or in an alter keeps the report.
drupal/unknown-plugin¶
- Level: warning
- Source: Drupal's plugin managers
A plugin manager throws a PluginNotFoundException for an unknown plugin id. A fallback
manager, such as the block manager, returns its fallback plugin instead, which doesn't do what the
code expects either.
Reports:
- a
createInstance()call with a literal plain id that no scanned plugin declares.
The check covers the plugin types that have a core plugin to look for: actions, archivers, blocks, conditions, entity reference selection, field types, formatters, widgets, mail, filters, image effects, media sources and REST resources.
Not reported:
- a
base:derivativeid, which comes from a deriver; - an id while the core plugin of its type isn't in the index. Without it, core is outside the analyzed code;
- a layout id, since core's layouts are mostly YAML, which the index doesn't hold;
- test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles). Tests mock managers.
Example
namespace Drupal\example\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
#[Block(id: 'example_block')]
class ExampleBlock extends BlockBase {
public function build(): array {
return [];
}
}
// Reported.
use Drupal\Core\Block\BlockManagerInterface;
function example_block(BlockManagerInterface $manager): object {
return $manager->createInstance('example_blok');
}
// Fine.
use Drupal\Core\Block\BlockManagerInterface;
function example_block(BlockManagerInterface $manager): object {
return $manager->createInstance('example_block');
}
Compared with phpstan-drupal
phpstan-drupal doesn't report an unknown plugin id.
drupal/plugin-manager-alter-info¶
- Level: warning
- Source: phpstan-drupal
- Ports: phpstan-drupal's
PluginManagerInspectionRule, the half foralterInfo()
phpstan-drupal reports a plugin manager that never calls alterInfo(). Without the call, other
modules can't alter the manager's plugin definitions.
Reports:
- a
DefaultPluginManagersubclass in the analyzed code whose own constructor never callsalterInfo().
The calls are read from the tokens of the constructor, so a call in a comment doesn't count. A
parent::__construct() call is followed into the parent's constructor, read from disk, so a manager
that leaves the call to its parent isn't reported.
Not reported:
- a manager whose service calls
alterInfo()throughcalls:, or through thecalls:of itsparent:service; - a class without a constructor of its own;
- an abstract class;
- test code (any file under a
testsdirectory); - a class whose constructor, or a parent constructor that it calls, can't be read from disk. See Finding the Drupal root.
Example
// Reported.
namespace Drupal\example;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
class ExampleManager extends DefaultPluginManager {
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Example', $namespaces, $module_handler);
$this->setCacheBackend($cache_backend, 'example_plugins');
}
}
// Fine.
namespace Drupal\example;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
class ExampleManager extends DefaultPluginManager {
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Example', $namespaces, $module_handler);
$this->alterInfo('example_info');
$this->setCacheBackend($cache_backend, 'example_plugins');
}
}
Compared with phpstan-drupal
- phpstan-drupal's rule is off by default. It runs with
pluginManagerInspectionRule: trueunderparameters.drupal.rules. The check is on by default. - phpstan-drupal checks every class that implements
PluginManagerInterfaceand extends another class, abstract classes included. The check takesDefaultPluginManagersubclasses that aren't abstract. - phpstan-drupal looks for the call in the constructor alone. The check also follows
parent::__construct()and reads thecalls:of the manager's services. - phpstan-drupal skips a class whose fully qualified name contains "test", in upper or lower
case, so a manager in a
Drupal\contestnamespace is skipped too. The check skips the files under atestsdirectory. - phpstan-drupal also reports a manager that calls
parent::__construct()when the first assignment in itsgetDiscovery()is anew YamlDiscovery(). The check doesn't port that half.
drupal/plugin-manager-cache-backend¶
- Level: warning
- Source: phpstan-drupal
- Ports: phpstan-drupal's
PluginManagerSetsCacheBackendRule
phpstan-drupal reports a plugin manager that never sets a cache backend. Without one, plugin discovery runs on every request.
Reports:
- a
DefaultPluginManagersubclass in the analyzed code whose own constructor never callssetCacheBackend().
The calls are read as for
drupal/plugin-manager-alter-info, with parent::__construct()
followed into the parent's constructor. A setCacheBackend() call without the cache key counts as
a call; Mago reports it as too-few-arguments, since core requires the key.
Not reported:
- a manager whose service calls
setCacheBackend()throughcalls:, or through thecalls:of itsparent:service; - a class without a constructor of its own;
- an abstract class;
- test code (any file under a
testsdirectory); - a class whose constructor, or a parent constructor that it calls, can't be read from disk.
Example
// Reported.
namespace Drupal\example;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
class ExampleManager extends DefaultPluginManager {
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Example', $namespaces, $module_handler);
$this->alterInfo('example_info');
}
}
// Fine.
namespace Drupal\example;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
class ExampleManager extends DefaultPluginManager {
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/Example', $namespaces, $module_handler);
$this->alterInfo('example_info');
$this->setCacheBackend($cache_backend, 'example_plugins');
}
}
Compared with phpstan-drupal
- phpstan-drupal checks every class that implements
PluginManagerInterface, abstract classes included. The check takesDefaultPluginManagersubclasses that aren't abstract. - phpstan-drupal looks for the call among the top-level statements of the constructor, so a call
inside an
ifis missed there. The check finds the call anywhere in the constructor, followsparent::__construct(), and reads thecalls:of the manager's services. - phpstan-drupal counts a call with fewer than two arguments as missing.
- phpstan-drupal also reports in test code.
drupal/plugin-annotation-context¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
PluginAnnotationContextDefinitionsRule
phpstan-drupal reports a plugin annotation that declares its contexts under the context key.
Drupal 8.7 renamed the key to context_definitions, and Drupal 9 removed the old one.
Reports:
- a plugin annotation with a
contextkey.
The annotation is read as the plugin index reads it.
Not reported:
- an annotation that doesn't count for the index, such as one on an abstract class or on a class that also has the attribute.
Example
// Reported.
namespace Drupal\example\Plugin\Block;
use Drupal\Core\Block\BlockBase;
/**
* Shows the title of the node.
*
* @Block(
* id = "example_title",
* admin_label = @Translation("Title"),
* context = {
* "node" = @ContextDefinition("entity:node"),
* },
* )
*/
class ExampleBlock extends BlockBase {
public function build(): array {
return [];
}
}
// Fine.
namespace Drupal\example\Plugin\Block;
use Drupal\Core\Block\BlockBase;
/**
* Shows the title of the node.
*
* @Block(
* id = "example_title",
* admin_label = @Translation("Title"),
* context_definitions = {
* "node" = @ContextDefinition("entity:node"),
* },
* )
*/
class ExampleBlock extends BlockBase {
public function build(): array {
return [];
}
}
Compared with phpstan-drupal
- phpstan-drupal checks a class that implements
ContextAwarePluginInterface. The check takes any class with a plugin annotation that the index reads. - phpstan-drupal looks for
context = {anywhere in the text of the docblock. The check reads the parsed annotation.