Skip to content

Register compiler extensions from attributes instead of an ordered config list#6102

Open
ondrejmirtes wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:deferred-autowired-parameters
Open

Register compiler extensions from attributes instead of an ordered config list#6102
ondrejmirtes wants to merge 4 commits into
phpstan:2.2.xfrom
phpstan-bot:deferred-autowired-parameters

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

The extensions: section of conf/config.neon listed ten compiler extensions whose order was load-bearing but documented nowhere. This removes the section, and first removes the reasons the order mattered.

I permuted the ten extensions and compared the generated containers. Two orderings out of the pairs were genuinely load-bearing, and both are fixed here.

1. #[AutowiredParameter] snapshotted parameter values

processConstructorParameters() resolved %foo% by reading ContainerBuilder::$parameters during loadConfiguration() and baking the value into the definition. ValidateExcludePathsExtension rewrites $parameters['excludePaths'] in its loadConfiguration() (unwrapping OptionalPath objects), so registering autowiredAttributeServices first handed FileExcluderFactory a list of OptionalPath objects:

TypeError: strlen(): Argument #1 ($string) must be of type string, PHPStan\DependencyInjection\Neon\OptionalPath given

References now compile to $this->getParameter('foo') lookups that read the final parameters at service creation time — the representation Nette already emits for dynamic parameters, so %foo.bar% becomes $this->getParameter('foo')['bar'], %foo%/suffix becomes a concatenation, and FactoryDefinition rewrites $this to $this->container inside generated factories. Parameter names cannot change after loadConfiguration(), so a typo in a reference is still reported while compiling.

Over 30 random permutations: 15 died on that TypeError before, all 30 produce byte-identical containers after.

2. Service tags were validated too early

ValidateServiceTagsExtension checked tags in beforeCompile(), but those hooks run in registration order, so it only saw tags added by extensions registered before it. Two cases escaped entirely: ConditionalTagsExtension when the two were swapped, and every extension registered from a project config file — the compiler always processes those after PHPStan's own. A bad tag then surfaced during analysis as Call to undefined method PHPStan\File\FileExcluderFactory::getNodeType().

Moving the check to afterCompile() — after every extension's beforeCompile(), and still before the tags matter — makes all 20 front/back positions report it, and covers third-party extensions for the first time.

The check now runs after ContainerBuilder::complete(). The test config registered a second autowired FileHelper, making DefaultStubFilesProvider's constructor ambiguous; that used to be masked because validation threw before autowiring ran. autowired: false keeps the fixture focused on the tag.

3. The list itself

The ten extensions now carry #[ContainerExtension(name: '...')] and are registered by ContainerExtensionsExtension, which ContainerFactory installs in the compiler's extensions slot.

Extending Nette's ExtensionsExtension and occupying that slot are both required. Compiler::processExtensions() runs loadConfiguration() on extensions matching ParametersExtension or ExtensionsExtension by instanceof, then snapshots its extension list and rejects later additions with Extensions ... were added while container was being compiled. So a plain CompilerExtension cannot register extensions even from that slot, and a subclass registered through extensions: does not exist yet when the snapshot is taken. Occupying the slot also keeps extensions: sections working — third-party extensions and conf/config.stubValidator.neon — through the parent implementation.

Registration is ordered by name so the container does not depend on the attribute collector's reporting order. That order happens to put autowiredAttributeServices before validateExcludePaths — the combination that was fatal before the first commit.

Verification

  • Generated container unchanged: all 600 createService() bodies byte-identical to those built from the extensions: section; reversing the registration order changes nothing either.
  • bin/phpstan analyse -l 8 src/Analyser/TypeSpecifier.php src/Rules/Comparison --error-format=json byte-identical to before.
  • Full make phpstan clean; 379 tests across DependencyInjection, File, Command, Php, Parallel green; StubValidatorIntegrationTest green (it exercises both the extensions: path and StubValidatorRuleServicesExtension).
  • Cold container build unchanged within noise (0.27–0.33s vs 0.27–0.28s over 3 runs); container grows 1.1% as inlined scalars become calls.

Behavior changes worth a look

  • A project config listing an extension under a name PHPStan already uses now fails with Name 'rules' is already used or reserved. instead of silently replacing PHPStan's own extension.
  • A wrongly tagged service in a config that also breaks autowiring now reports the Nette autowiring error rather than the tag message, since validation moved past complete().
  • Adding or renaming a #[ContainerExtension] requires composer dump-autoload, like the other attributes.
  • NeonAdapter::CACHE_KEY is bumped once, in the first commit.

Not included

No test pins the order-independence itself — the only way to exercise it is to permute the extensions: block, which no longer exists. Happy to add a unit test over the parameter-reference forms (%a%, %a.b%, %a%/suffix, %%, missing-parameter throw) if you want one.

🤖 Generated with Claude Code

https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6

ondrejmirtes and others added 4 commits July 25, 2026 22:38
… lookups

processConstructorParameters() resolved `%foo%` references by reading
ContainerBuilder::$parameters during loadConfiguration(), baking the value into
the service definition. That made the extension order in config.neon
load-bearing: ValidateExcludePathsExtension rewrites $parameters['excludePaths']
in its own loadConfiguration() (unwrapping OptionalPath objects), so registering
autowiredAttributeServices before validateExcludePaths handed FileExcluderFactory
a list of OptionalPath objects instead of strings and blew up with a TypeError.

References are now compiled into `$this->getParameter('foo')` lookups that read
the final parameters at service creation time, so no extension can leave a stale
snapshot behind. This is the representation Nette already emits for dynamic
parameters - `%foo.bar%` becomes `$this->getParameter('foo')['bar']`,
`%foo%/suffix` becomes a concatenation, and FactoryDefinition rewrites `$this` to
`$this->container` inside generated factories. Parameter names cannot change
after loadConfiguration(), so a typo in a reference is still reported while
compiling the container.

Verified with 30 random permutations of the ten extensions in config.neon: all
produce byte-identical containers, where previously 15 of them died on the
excludePaths TypeError. Analysis output is unchanged.

Bumps NeonAdapter::CACHE_KEY because the container cache key does not hash
PHPStan's own sources, so warm caches would keep serving the old container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6
…n are covered

ValidateServiceTagsExtension checked the tags in beforeCompile(), but beforeCompile()
hooks run in registration order, so it only ever saw tags added by extensions
registered before it in config.neon. Two cases escaped validation entirely:
ConditionalTagsExtension when the two were swapped, and every extension registered
from a project config file - the compiler always processes those after PHPStan's own
ones. A tag on a service not implementing the associated interface then surfaced
during analysis as "Call to undefined method FileExcluderFactory::getNodeType()"
instead of a message naming the tag and the interface.

afterCompile() runs after every extension's beforeCompile(), and tags added at that
point no longer reach the generated container, so it is the first moment the tag list
is complete. With the check there, all 20 front/back positions of the ten extensions
report the misconfiguration, and so does an extension registered from a project config.

Validation now happens after ContainerBuilder::complete(). The test config registered
a second autowired FileHelper, which makes DefaultStubFilesProvider's constructor
ambiguous - previously irrelevant because the tag check threw before autowiring ran.
Marking it autowired: false keeps the fixture focused on the tag.

Also builds the ReflectionClass lazily: it was constructed for every definition,
now only for those carrying a tag from the mapping.

The container cache key was already bumped earlier on this branch, so no separate
bump is needed to pick this up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6
The ten extensions PHPStan registers for itself had to be listed in the
`extensions:` section of conf/config.neon, in an order that mattered but was
documented nowhere. They now carry #[ContainerExtension(name: '...')] and are
registered by ContainerExtensionsExtension, so the section is gone.

ContainerExtensionsExtension extends Nette's ExtensionsExtension and ContainerFactory
installs it in the compiler's `extensions` slot. Both are required and neither is
cosmetic: Compiler::processExtensions() runs loadConfiguration() on the extensions
matching ParametersExtension or ExtensionsExtension by instanceof, then snapshots its
extension list and rejects anything registered afterwards with "Extensions ... were
added while container was being compiled". A plain CompilerExtension therefore cannot
register extensions even from that slot, and a subclass registered through
`extensions:` does not exist yet when the snapshot is taken. Occupying the slot also
makes this class responsible for `extensions:` sections of configuration files -
third-party extensions and conf/config.stubValidator.neon - which the parent
implementation keeps handling.

Extensions are registered ordered by name so the container does not depend on the
order the attribute collector reports classes in. That order puts
autowiredAttributeServices before validateExcludePaths, which was fatal until
#[AutowiredParameter] references stopped snapshotting parameter values.

The generated container is unchanged: all 600 createService() bodies are byte-identical
to the ones built from the `extensions:` section, and reversing the registration order
produces no difference either.

Note that a project config listing an extension under a name PHPStan already uses now
fails with "Name 'rules' is already used or reserved." instead of silently replacing
PHPStan's own extension, and that adding or renaming a #[ContainerExtension] requires
composer dump-autoload, like the other attributes.

No NeonAdapter::CACHE_KEY bump needed: the container cache key hashes
vendor/attributes.php, which gains the new attribute's targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QftMsBzTZP26Nn77ZxyR6
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.

1 participant