Skip to content

Give interface-typed entities magic field properties - #1053

Merged
mglaman merged 2 commits into
mainfrom
fix/entity-interface-magic-props
Sep 9, 2026
Merged

Give interface-typed entities magic field properties#1053
mglaman merged 2 commits into
mainfrom
fix/entity-interface-magic-props

Conversation

@mglaman

@mglaman mglaman commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Closes #1048. First slice of #929, scoped to ContentEntityInterface. Type inference fix.

What changed

A parameter typed ContentEntityInterface, NodeInterface, or any other interface extending ContentEntityInterface now gets the same magic field properties as a concrete entity class. Before, $entity->field_foo on such a value was reported as an undefined property while the same access on a Node worked.

Two layers needed fixing:

  • The extension's checks were false for the interface itself. EntityFieldsViaMagicReflectionExtension used implementsInterface() and EntityFieldReflection used isSubclassOfClass(). Both are false when the reflected class is the interface. Both now use is(), the same change Align legacy rules and reflections with current PHPStan API conventions #1027 made for FieldItemListInterface.
  • PHPStan never asked the extension about interfaces. PHPStan only consults property reflection extensions for a class that allows dynamic properties, which means a __get() declaration, an #[AllowDynamicProperties] attribute, a @phpstan-require-extends tag, or membership in universalObjectCratesClasses. Entity interfaces have none of these. extension.neon now lists Drupal\Core\Entity\ContentEntityInterface as a universal object crate, which matches every interface extending it. That is an honest description: ContentEntityBase::__get() accepts any property name.

EntityFieldReflection no longer needs a ReflectionProvider, so the extension's constructor no longer takes one.

Why not @phpstan-require-extends

A stub with @phpstan-require-extends ContentEntityBase opens the gate for ContentEntityInterface only. For NodeInterface, PHPStan then resolves properties through the required class before consulting the interface's own @property tags, and $node->book lost its BookData type. The crate list opens the gate for the interface hierarchy itself, so the annotations extension runs first and @property tags keep winning.

Side effects worth knowing

  • @property tags on entity interfaces now resolve. NodeInterface::$book and a project's own @property string $custom on an interface extending ContentEntityInterface were undefined-property errors before. This is a strict improvement.
  • With entityFieldsViaMagicReflection: false, entity field access is mixed instead of an undefined-property error. The crate registration is what opens the gate, and the crate extension is the fallback when ours is off. The README note under "Disabling extensions" documents this.

Testing

Unit cases for hasProperty() on ContentEntityInterface (true) and EntityInterface (false), and getProperty() on ContentEntityInterface. Fixture cases for field_myfield and original on a ContentEntityInterface parameter and field_myfield on a NodeInterface parameter. The existing book-module fixture guards the @property precedence. Full suite, self-analysis, and phpcs are green.

🤖 Generated with Claude Code

EntityFieldsViaMagicReflectionExtension and EntityFieldReflection used
implementsInterface() and isSubclassOfClass(), both false for the
interface itself, so a value typed ContentEntityInterface had no field
properties while a Node did. Both checks now use is().

That alone is not enough. PHPStan only consults property reflection
extensions for an interface that allows dynamic properties, and none
of the entity interfaces declare __get(). Registering
ContentEntityInterface as a universal object crate opens that gate for
it and for every interface extending it. As a side effect, @Property
tags declared on those interfaces now resolve too. With the extension
disabled, entity field access is typed mixed instead of reported as an
undefined property.

EntityFieldReflection no longer needs a ReflectionProvider, so the
extension no longer takes one either.

Closes #1048

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mglaman
mglaman merged commit 7ad658d into main Sep 9, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Values typed exactly EntityInterface or ContentEntityInterface get no magic field properties

1 participant