Core types and stubs¶
How the analyzer types the core methods documented by Drupal differently from how they behave, through type providers and through stub files.
Core return values¶
The drupal plugin types a few more of core's methods the way the code behaves:
$setting->id(); // string|null on a config entity, not int|string|null
$workspace->id(); // string|null on a content entity with a string ID field
$storage->loadMultipleRevisions([$a, $b]); // a list of int|string IDs is accepted
$queue->claimItem(); // (stdClass&object{data: mixed, item_id: int|string, created: int|string, ...})|false
$file_system->scanDirectory($dir, $mask); // array<int|string, stdClass&object{uri: string, filename: string, name: string, ...}>
$this->getTestFiles('image'); // list<stdClass&object{uri: string, filename: string, name: string, ...}>
$installer->validateUninstall(['mod']); // array<string, list<string|MarkupInterface>>
static::getEntityTypeEvents(); // array<string, list<array{0: string, 1: int}>>
parent::create($container); // static, in a form or controller
$manager->createHandlerInstance(X::class, $type); // X, not object
$query->addWhere(0, $field, $value); // the group may be an int
$url->toString(); // string
$url->toString(TRUE); // Drupal\Core\GeneratedUrl
$url->toString($flag); // Drupal\Core\GeneratedUrl|string
- Config entity IDs. A config entity's ID is its machine name, so a caller that passes one on only has to handle the null.
- Content entity IDs. A content entity type's ID is an integer, unless its
baseFieldDefinitions()creates the ID field ($fields['id']or$fields[$entity_type->getKey('id')]) as astringoruuidfield, asWorkspacedoes. An integer ID comes back from storage as a numeric string, so those keepint|string|null. The method is read up throughparent::baseFieldDefinitions()calls, and an interface is narrowed when every entity class that implements it has a string ID. - ID lists. The repository, revision storage and workspace methods that document an ID list as
int[]|string[]acceptarray<int|string>. The IDs of one entity type are all integers or all strings, but a list built fromid()calls is typedlist<int|string>. The rest of each signature is read from core's own declaration. - Queue items.
claimItem()is documented asbool|objectand never returns TRUE. The properties of the item come from the description of the method, and the database queue readsitem_idandcreatedas strings. Core's queues build the item as astdClass, and the type says so onQueueInterfaceand on core's queue classes, since Mago takes an object shape alone for something that can never be one. A queue outside core that returns an item of its own class through the interface gets animpossible-conditiononinstanceof. A queue class that declares its ownclaimItem()keeps what it declares. - Scanned files. The files that
scanDirectory()andgetTestFiles()find are the objects their descriptions list, built as astdClass, which is whatuploadNodeImage()in core's image tests takes. Core'spreviewNodeImage()documents anImageInterfacefor the same files, so those calls are reported aspossibly-invalid-argument: the docblock is wrong, not the call. - Views query groups. Views'
Sql::addWhere(),addWhereExpression()andaddHavingExpression()document$groupas a string, and tell the caller to use 0 for the default group. The groups are array keys, so the parameter takesint|string. The other parameters keep their declared types. - Uninstall reasons. Uninstall validators return translatable markup, which keeps their
placeholders safe to render. The
module-uninstall-validator.stubaccepts that on each validator. - Event lists.
EntityTypeEventSubscriberTraitandFieldStorageDefinitionEventSubscriberTraitbuild a list of[method, priority]pairs per event, which a subscriber can return as it is. - Handler instances.
createHandlerInstance()builds an instance of the class that it is handed, so a literal class name or aclass-string<X>gives that type. A plain string keeps core'sobject. - URLs.
Url::toString()is documented asstring|GeneratedUrl, and its argument decides which one comes back. A stub could say the same, but the descendants of a stubbed class inherit only what the stub declares, so a module's own subclass ofUrlwould lose every other method. create().ContainerInjectionInterface::create()returns a new instance of the called class, but doesn't document a return type. Mago gives acreate()that only says{@inheritdoc}that interface's missing type, even where a parent has the@return staticofAutowireTrait. So$instance = parent::create($container)in a form or controller isstatic, and so isFoo::create($container)when nocreate()on the way documents a type.
getLoadedRevisionId() stays the int documented by core, though it is NULL on a new or duplicated
entity. With the null in, Mago reports every == and != comparison with it, and the flag that
stops null reports elsewhere doesn't reach comparisons.
Inspector assertions¶
An Inspector::assert*() call that is true, as inside assert(), narrows the value that it checks,
as phpstan-drupal does. Each type is what the method tests:
assertAllStrings($names)givesiterable<mixed, string>;assertAllNumeric()counts numeric strings, since it usesis_numeric();assertStrictArray()gives a list;assertStringable($label)givesstring|\Stringable;assertAllObjects($items, Foo::class)gives an iterable ofFoo;assertAll()takes any callable, so it only makes amixedvalue iterable.
An assertion that the type already holds is reported as redundant-type-comparison.
phpstan-drupal narrows assertAllNumeric() to an iterable of int|float, so it reports the
assertion on numeric strings as always false. It narrows assertStrictArray() to an array with
non-negative int keys. It doesn't narrow with assertStringable().
Stub files¶
The worker loads the stub files under resources/stubs/ into Mago's symbol table before the
analysis starts. They are read for symbols only, and never linted, formatted or reported on.
$cache->get('cid'); // object{cid: string, data: mixed, created: int|float|numeric-string, expire: int|numeric-string, tags: list<string>, valid: bool, ...}|false
foreach ($entity as $name => $items) {} // string => Drupal\Core\Field\FieldItemListInterface
$entity->isRevisionTranslationAffected(); // bool|int|string|null
$validator->validate('module'); // array<string|MarkupInterface>
$this->t('@name', ['@name' => $label]); // reported when $label can be NULL
$permissions->getPermissions()[$name]['title']; // TranslatableMarkup|string
- Content entity fields. A
foreachover aContentEntityInterfacegives its field item lists, keyed by field name, asContentEntityBase::getIterator()builds them. Core doesn't name a value type for the\Traversablethat the interface extends, so the loop variables aremixedwithout the stub. - Revision translation flag. Core documents
isRevisionTranslationAffected()as returning a bool, butContentEntityBasereturns the raw field value:'1'or'0'from MySQL, an int from SQLite, or NULL when the flag isn't set. A(bool)cast on it is needed, so it isn't reported asredundant-cast. - Translation placeholders. The placeholder arguments of
t(),$this->t(),formatPlural(),TranslationInterface::translate()and theFormattableMarkup,TranslatableMarkupandPluralTranslatableMarkupconstructors are typed asarray<string, scalar|\Stringable>, as phpstan-drupal 2.2.2 types them. Core hands every value toHtml::escape(), which takes a string, so NULL, an array or an object without__toString()throws a TypeError. Mago reports a value that can be one of those aspossibly-invalid-argument, and amixedone asless-specific-nested-argument-type. - Permission titles. Core documents the title of a permission as a string, but
PermissionHandlerwraps the title and the description of every permission from a*.permissions.ymlfile int(), and the title from a permission callback is whatever the callback returns. So the title isTranslatableMarkup|string. - Entity IDs.
id()anduuid()ofEntityInterfaceonly read what the entity holds, so the stub marks them@psalm-mutation-free. Mago then keeps what a check on one call shows for the later calls on the same entity, up to a call that can change it, such assave(). Afterif ($entity->id() === NULL) { throw ...; }orisset($labels[$entity->uuid()]), the next call isn't null. This holds for a receiver typed asEntityInterfaceor one of its interfaces, such asContentEntityInterface. An entity class such asNodedeclares its ownid(), which isn't marked. PHPStan remembers what a check on any method call shows, on a class too, and keeps it acrosssave().
A stub only wins where core is a dependency. When core itself is the analyzed code, Mago keeps core's own declaration, so a run on core doesn't see a change.
How a stub replaces a class¶
A stub replaces the class header: extends, implements and the class constants come from the
stub. The stubbed class keeps the members of core's declaration that the stub leaves out, but its
descendants inherit only the members that the stub declares, and a stubbed interface or trait is
the stub alone. So a stub restates the whole header, and every member when the type is an
interface, a trait or a class with subclasses. A restated method's docblock replaces core's too,
@deprecated included, so the stubs repeat core's parameter types and deprecations. Each file
changes one or two members, and the rest are there to keep the type intact.
Core releases¶
A stub can follow the installed core release:
@stub-deprecated-in 11.2on a member drops its@deprecatedtag on a core older than 11.2;@stub-removed-in 12.0drops the member on 12.0 and later;@stub-added-in 11.3drops the member on a core older than 11.3.
So a stub doesn't claim a deprecation or a method that doesn't exist in the installed core. The
release is read from the root's Drupal::VERSION. With no core on disk, the stub is used as
written.
Not ported from phpstan-drupal¶
Not translated from phpstan-drupal's stub directory:
- the generics of the
TypedDataandFieldItemListhierarchies, about 60 files, most of them empty class bodies that only fix the order in which the inheritance is read by PHPStan; - the
@propertydocblocks of the field item classes. Each pins a parent class moved by core between releases, asEntityReferenceItemalready extendsEntityReferenceItemBasein 11.4, and they add warnings on concretely typed items; - the conditional returns of
AccessResult::allowedIf()andforbiddenIf(). A condition that isn't a literal gives the union, andAccessResultAlloweddoesn't implementAccessResultReasonInterface, soallowedIf($x)->setReason()becomes an error in the contrib code that calls it that way; $modulesonKernelTestBase. Restating that class header drops the PHPUnit ancestry that the test checks and every assertion depend on. Core'sBrowserTestBasealready documents$modulesasstring[];- whatever is already done by the plugin's own providers: entity storage, entity queries, container
lookups, config, plugin managers and
getDefinition(); - about 30 stubs that only restate a class or interface header, so that PHPStan reads the inheritance in the right order. Mago doesn't need them;
twig_escape_filter(). Twig 3 deprecates the function, and Mago reports a call to it as deprecated, which is right for the Twig that core ships;@phpstan-impureonLockBackendInterface::acquire(). Mago remembers a result from one call to the next only for a mutation-free method, andacquire()isn't one, so a secondacquire()afterwait()isn't taken to return what the first one did.
Not ported from phpstan-drupal's type extensions:
- narrowing one method's result by another call on the same object:
id()isn't null onceisNew()is false, andgetKey()orgetLinkTemplate()isn't false oncehasKey()orhasLinkTemplate()is true for the same key. A provider's assertion narrows a variable or a parameter, not what another method returns. A docblock assertion can name$this->id(), but Mago reads it both ways:@psalm-assert-if-false !null $this->id()onisNew()would also makeid()null wheneverisNew()is true, which is wrong afterenforceIsNew(). A call with arguments, such asgetKey('owner'), can't be named at all.getKey()is a string for the keys that every entity type has, and the plugin drops the reports about a possiblefalsefrom agetKey()orgetLinkTemplate()call that ahasKey()orhasLinkTemplate()check guards, see Entity type keys; - the
entity-type-iddocblock type.