Circular Dependency in Token Service during hook discovery
>>> [!note] Migrated issue <!-- Drupal.org comment --> <!-- Migrated from issue #3567623. --> Reported by: [freelock](https://www.drupal.org/user/313537) Related to !586 >>> <p>Hi,</p> <p>I'm still hitting circular dependency errors with ECA and Symfony Mailer, and after some digging and with the help of AI, I think the problem is actually due to 11.3's new hook discovery process triggering a dependency loop. The problem is, the ECA Content hook class ends up actually instantiating services during the service container rebuild, during a cache rebuild. If any other service is using dependency injection with the @token service, ECA gets into a dependency loop.</p> <p>I'm working around this for now by removing the token service from dependency injection and calling \Drupal::service('token') in these classes -- obviously the wrong solution.</p> <p>The rest of the bug report is an analysis with AI, that I think details the problem more fully. I have not tested the code in the next steps. I did have it create some PHPUnit tests, but I'm having trouble getting a running test environment for these.</p> <h3 id="summary-problem-motivation">Problem/Motivation</h3> <p>The ECA token service creates circular dependencies during Drupal 11.3+ hook discovery phase, preventing proper service injection and causing `ServiceCircularReferenceException` during cache rebuild operations and normal page requests after cache clear.</p> <p>Prevents cache rebuild operations and affects any module that attempts to inject the token service.</p> <p>Example error:</p> <p>Circular reference detected for service "Drupal\eca_content\Hook\ContentHooks",<br> path: "Drupal\canvas\Hook\ComponentSourceHooks -&gt; Drupal\canvas\CodeComponentDataProvider<br> -&gt; breadcrumb -&gt; book.breadcrumb -&gt; Drupal\eca_content\Hook\ContentHooks<br> -&gt; Drupal\mailer_override\OverrideManagerInterface -&gt; eca.service.token<br> -&gt; eca.token_data.current_user"<br> ```</p> <h4 id="summary-steps-reproduce">Steps to reproduce</h4> <p>1. Install ECA module and eca_content submodule<br> 2. Install symfony_mailer 2.0+ and mailer_override submodule (or any module that injects Token service in constructor)<br> 3. Clear cache via admin UI at `/admin/config/development/performance`<br> 4. Click "Clear all caches" button<br> 5. Observe white screen of death (WSOD)<br> 6. Check logs: `drush watchdog:show --type=php`</p> <h2>Root Cause Analysis</h2> <h3>The Core Problem</h3> <p>The circular dependency occurs during <strong>Drupal 11.3+ hook discovery phase</strong>, specifically when:</p> <ol> <li><strong>Hook Discovery</strong>: Drupal 11.3's new hook system discovers and instantiates hook classes with <code>#[Hook]</code> attributes during container compilation</li> <li><strong>Service Dependencies</strong>: Hook classes have service dependencies in their constructors that get instantiated</li> <li><strong>Token Service Instantiation</strong>: When a service depends on the token service, ECA's decoration kicks in</li> <li><strong>Heavy Component Loading</strong>: The ECA token service immediately loads all services tagged with <code>eca.token_data_provider</code></li> <li><strong>Circular Chain</strong>: These providers (like <code>eca.token_data.current_user</code>) depend on <code>entity_type.manager</code>, which during hook discovery can trigger services that depend back on token services</li> </ol> <h3>The Architectural Flaw</h3> <p><strong>Current problematic service definition:</strong></p> <pre>eca.service.token: decorates: token parent: token class: Drupal\eca\Token\CoreToken calls: - [setDecoratedToken, ['@eca.service.token.inner']] - [setEventDispatcher, ['@event_dispatcher']] tags: - { name: service_collector, tag: eca.token_data_provider, call: addTokenDataProvider }</pre><p><strong>The Problem:</strong></p> <p>The <code>service_collector</code> tag causes <strong>immediate instantiation</strong> of all services tagged with <code>eca.token_data_provider</code> when the token service is instantiated, including:</p> <ul> <li><code>eca.token_data.current_user</code> (depends on <code>entity_type.manager</code>)</li> <li>Other token data providers with heavy dependencies</li> </ul> <p><strong>This happens during:</strong></p> <ul> <li>Service instantiation (when token service is first requested)</li> <li>Hook discovery (when hook classes with token service dependencies are instantiated)</li> <li>Container initialization (when services with token dependencies are created)</li> </ul> <h3>Why This Affects Drupal 11.3+ Specifically</h3> <p>Drupal 11.3 introduced a new hook discovery system that:</p> <ul> <li>Instantiates hook classes earlier during container compilation</li> <li>Processes <code>#[Hook]</code> attributes during service discovery</li> <li>Triggers service instantiation during the hook registration phase</li> <li>Exposes the existing circular dependency issue that was previously hidden</li> </ul> <h3>Current Service Architecture</h3> <p><strong>eca.service.token service:</strong></p> <pre>eca.service.token: decorates: token class: Drupal\eca\Token\CoreToken tags: - { name: service_collector, tag: eca.token_data_provider, call: addTokenDataProvider }</pre><p><strong>eca.token_data.current_user service:</strong></p> <pre>eca.token_data.current_user: class: Drupal\eca\Token\CurrentUserDataProvider arguments: - '@current_user' - '@entity_type.manager' # This creates the circular dependency tags: - { name: eca.token_data_provider, priority: -100 }</pre><h3>The Circular Dependency Chain</h3> <div class="flow-diagram"> Hook Discovery<br><br> &nbsp;&nbsp;&darr;<br><br> Hook Class Instantiation (e.g., ContentHooks)<br><br> &nbsp;&nbsp;&darr;<br><br> Service Dependencies (e.g., OverrideManager)<br><br> &nbsp;&nbsp;&darr;<br><br> Token Service Instantiation (eca.service.token)<br><br> &nbsp;&nbsp;&darr;<br><br> Service Collector Triggers (loads all eca.token_data_provider services)<br><br> &nbsp;&nbsp;&darr;<br><br> eca.token_data.current_user Instantiation<br><br> &nbsp;&nbsp;&darr;<br><br> entity_type.manager Instantiation<br><br> &nbsp;&nbsp;&darr;<br><br> Hook Discovery (circular - back to start!) </div> <h2>Current Workarounds</h2> <p><strong>Affected modules are forced to use service locator pattern:</strong></p> <pre>// Instead of dependency injection (correct pattern): public function __construct(Token $token) { $this-&gt;token = $token; } // Forced to use service locator (workaround): public function someMethod() { \Drupal::service('token')-&gt;replace($value); }</pre><div class="warning-box"> <p><strong>This is not ideal</strong> because:</p> <ul> <li>Prevents proper dependency injection</li> <li>Violates SOLID principles</li> <li>Makes testing more difficult</li> <li>Reduces code maintainability</li> </ul> </div> <h3 id="summary-proposed-resolution">Proposed resolution</h3> <p>Implement lazy loading for token data providers to defer heavy dependency instantiation until tokens are actually processed.</p> <h3 id="summary-remaining-tasks">Remaining tasks</h3> <h4>1. Create Lazy Token Data Provider Support</h4> <pre>// New interface for lazy loading interface LazyTokenDataProviderInterface extends TokenDataProviderInterface { /** * Determines if provider should be loaded. * * @return bool * TRUE if provider is ready to load, FALSE to defer. */ public function isReady(): bool; }</pre><h4>2. Modify CoreToken to Support Lazy Loading</h4> <pre>class CoreToken extends Token implements TokenInterface { /** * Token data providers (lazy-loaded). */ protected ?array $tokenDataProviders = null; /** * Gets token data providers, loading them lazily. */ protected function getTokenDataProviders(): array { if ($this-&gt;tokenDataProviders === null) { $this-&gt;tokenDataProviders = []; foreach ($this-&gt;collectedProviders as $provider) { // Only load providers that are ready if ($provider instanceof LazyTokenDataProviderInterface &amp;&amp; !$provider-&gt;isReady()) { continue; // Skip providers that aren't ready yet } $this-&gt;tokenDataProviders[] = $provider; } } return $this-&gt;tokenDataProviders; } /** * {@inheritdoc} */ public function generate($type, array $data = [], array $options = [], BubbleableMetadata $bubbleable_metadata = null): array { // Only load providers when actually processing tokens, not during construction $providers = $this-&gt;getTokenDataProviders(); // ... rest of token processing } }</pre><h4>3. Update CurrentUserDataProvider for Lazy Loading</h4> <pre>class CurrentUserDataProvider implements TokenDataProviderInterface, LazyTokenDataProviderInterface { /** * Entity type manager (lazy-loaded). */ protected ?EntityTypeManagerInterface $entityTypeManager = null; public function __construct( protected AccountProxyInterface $currentUser, // Remove entity_type.manager from constructor to avoid circular dependency ) {} /** * {@inheritdoc} */ public function isReady(): bool { // Only ready if we're not during hook discovery return \Drupal::hasService('entity_type.manager') &amp;&amp; \Drupal::hasService('cache.entity'); } /** * Gets entity type manager, loading it lazily. */ protected function getEntityTypeManager(): EntityTypeManagerInterface { if ($this-&gt;entityTypeManager === null) { $this-&gt;entityTypeManager = \Drupal::service('entity_type.manager'); } return $this-&gt;entityTypeManager; } }</pre><h4>4. Update Service Definitions</h4> <pre>eca.token_data.current_user: class: Drupal\eca\Token\LazyCurrentUserDataProvider arguments: - '@current_user' # entity_type.manager loaded lazily, not in constructor tags: - { name: eca.token_data_provider, priority: -100 }</pre><h3 id="summary-ui-changes">User interface changes</h3> <p>None.</p> <h3 id="summary-api-changes">API changes</h3> <p>Hopefully none?</p> <h3 id="summary-data-model-changes">Data model changes</h3> <p>None.</p>
issue