Skip to content

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: EntityStorageInterface for storage, and the access control, view builder and list builder interfaces for access, view_builder and list_builder, since getStorage($id) is getHandler($id, 'storage'). Other handler types keep object.
  • 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 is FALSE, 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 SqlContentEntityStorage and EntityViewBuilder;
  • 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() and createRevision() 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() and loadByProperties() key their result by entity id. That is string for a config entity, whose id is a machine name, as phpstan-drupal types it, and int|string for a content entity, where phpstan-drupal says int. A config entity with a numeric id such as '1' ends up under an integer key at runtime, which this doesn't cover;
  • the EntityRepositoryInterface lookups are typed by their entity type id argument, and getTranslationFromContext($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 EntityType constructor fills in the last five, with an empty string for revision, bundle and langcode, and getKey() returns what is set, so an absent revision key is '', not FALSE;
  • core requires an id key of every entity type that can be saved, so id is typed as always there. A check such as getKey('id') === FALSE for an entity type without one, like core's test type entity_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 if or elseif condition 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 the if guards;
  • an if whose whole condition is the negated check, such as if (!$entity_type->hasKey('owner')), has a block that ends in throw or return, and the call comes after it, in the same block as the if. The if starts 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 a list;
  • a call after an assignment to the receiver or to the key variable, made between the check and the call;
  • a call in an else branch, after the if, in a ternary, or in a closure or an arrow function inside the if;
  • a call guarded by a condition or a statement with ||, or, xor, ?? or ?, an if (!hasKey()) whose block doesn't end in throw or return, 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 an elseif (!hasKey()) or an else 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 TRUE or FALSE;
  • 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 still string|false for 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() and getFieldDefinitions();
  • the entity field manager's getBaseFieldDefinitions(), getFieldDefinitions() and getFieldStorageDefinitions().

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> after count();
  • 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, FieldDefinition or FieldStorageDefinition, as in baseFieldDefinitions() and the field info hooks;
  • 'field_name' => '...' values, as in FieldStorageConfig::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 as createImageField('images', ...);
  • installFieldStorageDefinition('...') calls in update hooks;
  • the values of entity_keys and revision_metadata_keys, whose fields ContentEntityBase and 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 extends ContentEntityBase, whose magic methods read and write fields and any other name, including the $account->pass_raw that tests set;
  • for original, on any entity interface, since EntityBase serves 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() and hook_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 user entity type isn't in the index. Without it, core is outside the analyzed code;
  • test code (any file under a tests directory) and hook documentation (*.api.php files).
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 an accessCheck() 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() and getAggregateQuery(). The check also tracks \Drupal::entityQueryAggregate().

drupal/deprecated-original

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 an unset() of original on an entity.

Not reported:

  • an entity class that declares $original itself;
  • 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 tests directory) and hook documentation (*.api.php files).
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 original property as deprecated.
  • phpstan-drupal types original as a non-null ContentEntityInterface on a content entity, so PHPStan reports ?-> on it as nullsafe.neverNull. On a plain EntityInterface, PHPStan reports the property as property.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 @ConfigEntityType annotation.

Not reported:

  • an abstract class.
Example
modules/example/src/Entity/ExampleSetting.php
// 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 @ConfigEntityType annotation. 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() or getDefaultOperations() method without a second parameter typed as CacheableMetadata, on core 11.3 and later 11.x releases. The method is one declared on a class that implements EntityListBuilderInterface.

Not reported:

  • core's own EntityListBuilder, which reads the parameter through func_get_args();
  • a method that the class inherits;
  • anything when the core version can't be read from core/lib/Drupal.php.
Example
modules/example/src/ExampleListBuilder.php
// 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 of EntityListBuilder. The check reports it on any class that implements EntityListBuilderInterface.
  • phpstan-drupal reads the core version from the loaded Drupal class. The check reads it from core/lib/Drupal.php under the Drupal root.