Entities¶
The analyzer reads the entity types of the codebase, and types what the entity type manager, entity
handlers, entity queries and entity fields return. The checks report unknown entity type ids,
entity queries without an access check, uses of the deprecated original property, and entity type
definitions and list builders that leave out what core asks for.
The entity type index¶
The analyzer indexes every class with a #[ContentEntityType], #[ConfigEntityType] or
#[EntityType] attribute, from Mago's class metadata. Vendor code is indexed too, so a contrib
workspace sees core's entity types.
Contrib still ships the legacy @ContentEntityType, @ConfigEntityType and @EntityType docblock
annotations, so the analyzer also reads them, from the src/Entity/*.php files of the modules under
the Drupal root. See The plugin index for how a file that an editor
hasn't saved counts. A class that also has the attribute has its annotation ignored, the way
Drupal's discovery does.
Two classes that declare one entity type id cancel each other out, as plugins do. The id is known, so it isn't reported, but it doesn't name a definition.
Not indexed:
- a class whose parent chain Mago couldn't resolve;
- an annotation in vendor code outside the Drupal root.
Handlers and storage¶
The entity type manager, the storages and the repositories are typed from the entity type that an id names:
$etm->getStorage('node'); // Drupal\node\NodeStorage<'node'>
$etm->getStorage('node')->load(1); // Drupal\node\Entity\Node|null
$etm->getStorage('node')->loadMultiple(); // array<int|string, Drupal\node\Entity\Node>
$etm->getStorage('view')->loadMultiple(); // array<string, Drupal\views\Entity\View>
$etm->getStorage('block_content')->create(); // Drupal\block_content\Entity\BlockContent
$etm->getAccessControlHandler('node'); // Drupal\node\NodeAccessControlHandler<'node'>
$etm->getFormObject('node', 'edit'); // Drupal\node\NodeForm<'node'>
$etm->getDefinition('node'); // Drupal\Core\Entity\ContentEntityTypeInterface
$etm->getDefinition('node', FALSE); // Drupal\Core\Entity\ContentEntityTypeInterface|null
$etm->getDefinition($id); // Drupal\Core\Entity\EntityTypeInterface
$etm->getHandler($id, 'storage'); // Drupal\Core\Entity\EntityStorageInterface
$typeRepository->getEntityTypeFromClass(Node::class); // 'node'
$storage->getEntityTypeId(); // 'node'
$repository->loadEntityByUuid('taxonomy_term', $uuid); // Drupal\taxonomy\Entity\Term|null
getHandler()for an entity type that the index can't resolve returns what is declared by core on the getter of that handler type:EntityStorageInterfaceforstorage, and the access control, view builder and list builder interfaces foraccess,view_builderandlist_builder, sincegetStorage($id)isgetHandler($id, 'storage'). Other handler types keepobject.- An id that is one of several literals, such as
'node'|'user', gives the union of their handlers or definitions. Once one of them is unknown, the getter keeps its declared type. getDefinition()throws instead of returning null unless its second argument isFALSE, so the null goes away, even for an entity type id that the index can't resolve. Only a computed second argument keeps the declared type.
A handler type has the entity type id as a type parameter, as in NodeStorage<'node'>. A later
call on that value reads the parameter, so ->load() on a SqlContentEntityStorage that many
entity types share still knows which entity class comes back. The parameter is trusted only on a
class that the index knows as one of that entity type's handlers. A storage that arrives without
the parameter, for example injected as EntityStorageInterface, still resolves when its class
serves exactly one entity type. A receiver that may be either of two storages resolves to neither,
and so does one that may also be a storage of no known entity type.
The default handlers follow core's entity type classes:
- a content entity type gets
SqlContentEntityStorageandEntityViewBuilder; - a config entity type gets
ConfigEntityStorage; - every kind gets
EntityAccessControlHandler; - list builders and forms don't have a default;
- a bare
#[EntityType]doesn't get a storage default, and its definition isn't narrowed to a definition interface.
The storage methods:
- the revisionable storage methods
loadRevision(),loadRevisionUnchanged(),loadMultipleRevisions()andcreateRevision()are typed as well. On a storage of no known entity class,createRevision()returns the type of the entity that it's handed, when that's a revisionable entity, since the new revision has the same class; loadMultiple()andloadByProperties()key their result by entity id. That isstringfor a config entity, whose id is a machine name, as phpstan-drupal types it, andint|stringfor a content entity, where phpstan-drupal saysint. A config entity with a numeric id such as'1'ends up under an integer key at runtime, which this doesn't cover;- the
EntityRepositoryInterfacelookups are typed by their entity type id argument, andgetTranslationFromContext($entity)returns the class that it was handed.
Entity type keys¶
EntityTypeInterface::getKey() is documented as string|false. For id, revision, bundle,
langcode, default_langcode and revision_translation_affected, the analyzer types it as
string:
- the
EntityTypeconstructor fills in the last five, with an empty string forrevision,bundleandlangcode, andgetKey()returns what is set, so an absentrevisionkey is'', notFALSE; - core requires an
idkey of every entity type that can be saved, soidis typed as always there. A check such asgetKey('id') === FALSEfor an entity type without one, like core's test typeentity_test_no_id, is reported as impossible.
Any other key keeps string|false.
$entity_type->getKey('id'); // string
getLinkTemplate() is documented as string|false as well, and keeps that type for every
template.
Mago doesn't narrow one call's result by another call on the same object, so getKey() stays
string|false after hasKey() is true for the same key, and getLinkTemplate() stays
string|false after hasLinkTemplate(). The plugin drops the reports about the possible false
instead, when the code around the call shows the check. The reports are
falsable-return-statement, possibly-false-argument and possibly-false-operand, on the
getKey() or getLinkTemplate() call itself. invalid-return-statement and
mismatched-array-index on the call are about the whole string|false type, so they're dropped
only when a string fits: the declared return type holds every string, as string, ?string or
int|string do, or the call is the key of an array write, or of a read from an array whose key type
holds every string, such as array<string, mixed>. Mago prints a literal string type without
escaping the quotes in it, so the plugin doesn't read the text between quotes in the type. A
declared type such as string|'a' still fits a string, but a key type next to a literal, as in
array<string, 'a'>, doesn't count, and the report stays.
The call is on a variable or a chain of properties, with a string literal or a variable as the key,
and in the same function. The check can stand alone or be compared with TRUE, as in
hasKey('owner') === TRUE, and the negated check is !hasKey('owner') or a comparison with
FALSE, as in hasKey('owner') === FALSE or FALSE == hasKey('owner'):
- an
iforelseifcondition holds the check on the same receiver with the same key, joined to its other parts by&&only, and the call comes later in that condition or in the statement that theifguards; - an
ifwhose whole condition is the negated check, such asif (!$entity_type->hasKey('owner')), has a block that ends inthroworreturn, and the call comes after it, in the same block as theif. Theifstarts a statement of its own; - a statement, such as an assignment or a
return, holds the check as one of its&&parts outside any parentheses, and the call comes later in the same&&chain.
if ($entity_type->hasKey('owner')) {
$query->condition($entity_type->getKey('owner'), $uid); // no possibly-false-argument
}
// No possibly-false-argument on the getLinkTemplate() call either.
$pin = $type->hasLinkTemplate('edit') && str_contains($type->getLinkTemplate('edit'), '{');
Still reported:
- a guarded call whose string doesn't fit either, such as one returned from a function declared to
return
int, or used as a key to read alist; - a call after an assignment to the receiver or to the key variable, made between the check and the call;
- a call in an
elsebranch, after theif, in a ternary, or in a closure or an arrow function inside theif; - a call guarded by a condition or a statement with
||,or,xor,??or?, anif (!hasKey())whose block doesn't end inthroworreturn, and a negated check in an&&chain; - a call in the branch where the check is false, as in
if ($entity_type->hasKey('owner') === FALSE), and a call after anelseif (!hasKey())or anelse if (!hasKey())that exits, since that branch doesn't run when an earlier one does; - a call after a check compared with anything other than
TRUEorFALSE; - a call after a check held in another call, as in
check($entity_type->hasKey('owner')) && ..., and a call after a comma or a=>, as in another element of an array; - a call after a check of the getter itself, as in
if ($entity_type->getKey('label') !== FALSE); - a call on a receiver that is itself a call, as in
$entity->getEntityType()->getKey('label'); - the result once assigned, as in
$key = $entity_type->getKey('owner'), which is stillstring|falsefor later uses.
phpstan-drupal narrows the type of the getKey() or getLinkTemplate() call itself after the
check. So it also leaves alone the ternary, the closure, the call on another call and the assigned
result, and PHPStan remembers a getter call compared with FALSE. After a hasLinkTemplate() that
is false, phpstan-drupal also narrows getLinkTemplate() to false.
Access checks¶
EntityAccessControlHandlerInterface::access(), createAccess() and fieldAccess() return an
AccessResultInterface when $return_as_object is a literal TRUE, and a bool otherwise.
Drupal 11.4 says the same with a conditional return type, which is read by Mago, so this matters
only with older core.
$handler->access($entity, 'view'); // bool
$handler->access($entity, 'view', NULL, TRUE); // Drupal\Core\Access\AccessResultInterface
Keyed lists¶
Core documents these lists as EntityTypeInterface[], FieldItemListInterface[] and so on. The
analyzer keys them by string:
- the entity type manager's
getDefinitions(); - the entity's
getFields(),getTranslatableFields()andgetFieldDefinitions(); - the entity field manager's
getBaseFieldDefinitions(),getFieldDefinitions()andgetFieldStorageDefinitions().
The keys are entity type ids and field names. A field name matches /^[_a-z]+[_a-z0-9]*$/, and no
entity type id is numeric, so PHP never turns one into an integer key, and a key from
array_keys() or foreach can be passed to a method that takes a string. phpstan-drupal's stubs
type the entity's field methods the same way. The values keep the type documented by core.
getTranslationLanguages() and the language manager's getLanguages() are keyed by language code,
as string, where core documents LanguageInterface[]. A language code matches
LanguageInterface::VALID_LANGCODE_REGEX, which starts with a letter, so PHP never turns one into
an integer key, and array_keys($entity->getTranslationLanguages()) hands a language code to
getTranslation() without a less-specific-argument.
List builder operations¶
Core 11.3 and later 11.x releases keep the $cacheability parameter of the list builder's
getOperations() and getDefaultOperations() commented out, and read it through func_get_args().
Its own list builders pass it on with parent::getOperations($entity, $cacheability). The analyzer
declares the parameter for those calls, so they aren't reported as too-many-arguments. A named
cacheability: argument fails at runtime on those versions, so that call keeps the declared
signature. A list builder that leaves the parameter out is reported as
drupal/list-builder-cacheability.
Entity queries¶
getQuery(), getAggregateQuery(), \Drupal::entityQuery() and \Drupal::entityQueryAggregate()
return a query tagged with its entity type, its kind, whether access is checked and what
execute() returns, as in QueryInterface<'node', 'content', 'unchecked', 'ids'>. accessCheck()
and count() update the tags through the fluent chain, and execute() reads them. It returns:
int<0, max>aftercount();array<int|string, string>of ids for a content query;array<string, string>for a config query;list<array<string, mixed>>for an aggregate query.
A query whose entity type isn't known, from \Drupal::entityQuery($id) or from getQuery() on a
bare EntityStorageInterface, is tagged with an unknown entity type, so the chain is still
tracked. A query that runs without accessCheck() is reported as
drupal/entity-query-access-check.
Magic entity fields¶
ContentEntityBase::__get() returns a field item list for any field name, so an undeclared
property on a FieldableEntityInterface descendant is typed as
Drupal\Core\Field\FieldItemListInterface. FieldItemList::__get() forwards to the first item,
and FieldItemBase::__get() reads the properties that the field type defines, so an undeclared
property on a field item list or on a field item is accepted as mixed. The field type decides
what comes back, and typing it would be a guess.
$node->field_thing; // Drupal\Core\Field\FieldItemListInterface
$node->field_thing->value; // mixed, not an undefined property
$item->value; // mixed on a FieldItemInterface as well
$node->field_thing = 'a string'; // allowed, the way __set() is
$node->original; // Drupal\node\NodeInterface|null, the entity's own type, reported as deprecated
Writing a field stays mixed, because $node->field_thing = 'x' is valid Drupal. A property that
PHP itself resolves keeps its own type: a declared property, an inherited one and a @property tag
all win over the field type.
Field names¶
ContentEntityBase::__get() reads a name that isn't a field from the entity's plain values, which
code uses for ad hoc properties such as $comment->in_preview or the $account->pass_raw that
tests set, and __isset() checks those values. So a name is typed as a field only when some code or
config under the Drupal root defines a field with it, and any other name is mixed.
if ($comment->in_preview) is then not an always-true condition, and 'x' . $account->pass_raw
gets Mago's usual report for a mixed operand, not one for an object without __toString().
The analyzer reads the field names from every PHP file of the extensions and of core/tests:
- string keys given a
BaseFieldDefinition,BundleFieldDefinition,FieldDefinitionorFieldStorageDefinition, as inbaseFieldDefinitions()and the field info hooks; 'field_name' => '...'values, as inFieldStorageConfig::create();- the literal that a test keeps its field name in, such as
$field_name = 'images'or$this->fieldName = 'test_text', and the first argument of helpers such ascreateImageField('images', ...); installFieldStorageDefinition('...')calls in update hooks;- the values of
entity_keysandrevision_metadata_keys, whose fieldsContentEntityBaseand its traits create.
The file names of the field.storage.* and field.field.* config in the extensions add theirs.
A name that starts with field_, the Field UI's prefix, is a field too, since a site's active
config isn't read.
The names aren't split by entity type, so a field of one entity type is typed as a field on every
entity. That keeps the field type possibly undefined: isset(), empty() and ?? on such a
property aren't reported as redundant. With no field definition found at all, as in a workspace
without a Drupal root, every lowercase name counts as a field.
These fields are typed as mixed:
- a field that some code defines with a name built at runtime, such as
$fields[$name]; - a field that a migration creates from its source data;
- a field that only a site's active config has, without the
field_prefix.
The original entity¶
original is typed as the entity before the save, not as a field, on config entities too. It has
the entity's own type, as getOriginal() returns ?static. Drupal 11.2 deprecates the magic
property in favor of getOriginal() and setOriginal(), and its use is reported as
drupal/deprecated-original.
Referenced entities¶
referencedEntities() is accepted on any field item list, as phpstan-drupal does. phpstan-drupal
types its result as mixed, and the analyzer returns what EntityReferenceFieldItemListInterface
declares. Only a reference field's list has the method, but $node->field_tags and
$node->get('field_tags') give the plain FieldItemListInterface. On a list of another field type
the call throws, which the types can't tell apart.
Entity interfaces¶
When the receiver's static type is an interface, such as NodeInterface, Mago reports these
accesses as missing-magic-method, since an interface doesn't declare __get() or __set(). The
analyzer drops that report:
- on any interface that extends
FieldableEntityInterface. Every content entity class extendsContentEntityBase, whose magic methods read and write fields and any other name, including the$account->pass_rawthat tests set; - for
original, on any entity interface, sinceEntityBaseserves that one.
A concrete class without the magic method keeps the report, and so does a @property tag on any
other interface.
Not modeled¶
These keep the types that the entity type attribute gives:
hook_entity_type_build()andhook_entity_type_alter()implementations that swap handlers or entity classes;- a handler class not scanned by Mago, which keeps the declared interface type.
drupal/unknown-entity-type¶
- Level: warning
- Source: Drupal's entity type manager
The entity type manager throws a PluginNotFoundException for an entity type id that no entity
type declares.
Reports:
- a literal entity type id that no indexed entity type declares, passed to a getter of the entity type manager.
The getters are getStorage(), getAccessControlHandler(), getViewBuilder(), getListBuilder(),
getFormObject(), getHandler() and getDefinition(). A named argument counts, as in
getHandler(handler_type: 'access', entity_type_id: 'node').
Not reported:
getDefinition($id, FALSE), which asks for null instead of an exception;- a lookup after a
hasDefinition()call with the same literal id, earlier in the same function. Code does this around a lookup of an optional module's entity type, or before it returns early; - any id while the
userentity type isn't in the index. Without it, core is outside the analyzed code; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
function example_storage(EntityTypeManagerInterface $manager): EntityStorageInterface {
return $manager->getStorage('usr');
}
// Fine.
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
function example_storage(EntityTypeManagerInterface $manager): EntityStorageInterface {
return $manager->getStorage('user');
}
Compared with phpstan-drupal
phpstan-drupal doesn't report an unknown entity type id.
drupal/entity-query-access-check¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
EntityQueryHasAccessCheckRule
phpstan-drupal reports an entity query that runs without a decision about access checking. Since Drupal 10 such a query throws. Config entity queries don't have access checking, so they are left alone.
Reports:
execute()on an entity query whose chain doesn't have anaccessCheck()call, unless the entity type is a known config entity type;- the same on a query of an unknown entity type.
accessCheck(FALSE) is a decision, and isn't reported. $query->accessCheck(TRUE); as a statement
of its own counts for the variable, so the fluent chain isn't needed. A chain that ends in it, such
as $query->condition($group)->accessCheck(TRUE);, counts for the result of the chain and not for
$query, so a later $query->execute() is reported. Call accessCheck() on the variable itself
there.
Not reported:
- a query on a bare
ConfigEntityStorageInterface, which is a config query of unknown type; - a query that the analyzer never saw being created. The check reads the tags of the query alone, so a query built in one method and run in another is tracked only when the tagged type flows through.
Example
// Reported.
function example_active_users(): array {
return \Drupal::entityQuery('user')->condition('status', 1)->execute();
}
// Fine.
function example_active_users(): array {
return \Drupal::entityQuery('user')->condition('status', 1)->accessCheck(TRUE)->execute();
}
Compared with phpstan-drupal
- phpstan-drupal tracks the queries of
\Drupal::entityQuery(),getQuery()andgetAggregateQuery(). The check also tracks\Drupal::entityQueryAggregate().
drupal/deprecated-original¶
- Level: warning
- Source: Drupal's deprecation of the
originalproperty
Drupal 11.2 deprecates the magic original property of an entity in favor of getOriginal() and
setOriginal(). Drupal 12 removes it.
Reports:
- a read, a write, an
isset()or anunset()oforiginalon an entity.
Not reported:
- an entity class that declares
$originalitself; - code in a deprecation scope, as for Mago's own deprecation codes;
- any use while core doesn't have
EntityInterface::getOriginal(), which Drupal 11.2 added; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
use Drupal\Core\Entity\EntityInterface;
function example_label_changed(EntityInterface $entity): bool {
return $entity->original?->label() !== $entity->label();
}
// Fine.
use Drupal\Core\Entity\EntityInterface;
function example_label_changed(EntityInterface $entity): bool {
return $entity->getOriginal()?->label() !== $entity->label();
}
Compared with phpstan-drupal
- phpstan-drupal doesn't report the
originalproperty as deprecated. - phpstan-drupal types
originalas a non-nullContentEntityInterfaceon a content entity, so PHPStan reports?->on it asnullsafe.neverNull. On a plainEntityInterface, PHPStan reports the property asproperty.notFound.
drupal/config-entity-export¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
ConfigEntityConfigExportRule
phpstan-drupal reports a config entity type without a config_export key. Without the key,
ConfigEntityBase::toArray() throws a SchemaIncompleteException, so Drupal can't export the
entity.
Reports:
- a config entity type in the entity type index without
config_export. The type can come from a#[ConfigEntityType]attribute or a@ConfigEntityTypeannotation.
Not reported:
- an abstract class.
Example
// Reported.
namespace Drupal\example\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\Attribute\ConfigEntityType;
#[ConfigEntityType(id: 'example_setting')]
class ExampleSetting extends ConfigEntityBase {}
// Fine.
namespace Drupal\example\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\Attribute\ConfigEntityType;
#[ConfigEntityType(id: 'example_setting', config_export: ['id', 'label'])]
class ExampleSetting extends ConfigEntityBase {}
Compared with phpstan-drupal
- phpstan-drupal checks the
@ConfigEntityTypeannotation. The check also reads the#[ConfigEntityType]attribute. - phpstan-drupal looks for
config_export = {in the text of the docblock. The check reads the parsed annotation.
drupal/list-builder-cacheability¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
EntityListBuilderOperationsCacheabilityRule
phpstan-drupal reports a list builder's operation method that leaves out the CacheableMetadata
parameter added in Drupal 11.3. Core 11.3 and later 11.x releases keep the parameter commented out
in the interface and read it through func_get_args().
Reports:
- a list builder's
getOperations()orgetDefaultOperations()method without a second parameter typed asCacheableMetadata, on core 11.3 and later 11.x releases. The method is one declared on a class that implementsEntityListBuilderInterface.
Not reported:
- core's own
EntityListBuilder, which reads the parameter throughfunc_get_args(); - a method that the class inherits;
- anything when the core version can't be read from
core/lib/Drupal.php.
Example
// Reported.
namespace Drupal\example;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
class ExampleListBuilder extends EntityListBuilder {
public function getOperations(EntityInterface $entity): array {
return parent::getOperations($entity);
}
}
// Fine.
namespace Drupal\example;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
class ExampleListBuilder extends EntityListBuilder {
public function getOperations(EntityInterface $entity, ?CacheableMetadata $cacheability = NULL): array {
return parent::getOperations($entity, $cacheability);
}
}
Compared with phpstan-drupal
- phpstan-drupal reports
getDefaultOperations()only on a subclass ofEntityListBuilder. The check reports it on any class that implementsEntityListBuilderInterface. - phpstan-drupal reads the core version from the loaded
Drupalclass. The check reads it fromcore/lib/Drupal.phpunder the Drupal root.