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 -> Drupal\canvas\CodeComponentDataProvider<br>
-> breadcrumb -> book.breadcrumb -> Drupal\eca_content\Hook\ContentHooks<br>
-> Drupal\mailer_override\OverrideManagerInterface -> eca.service.token<br>
-> 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>
↓<br><br>
Hook Class Instantiation (e.g., ContentHooks)<br><br>
↓<br><br>
Service Dependencies (e.g., OverrideManager)<br><br>
↓<br><br>
Token Service Instantiation (eca.service.token)<br><br>
↓<br><br>
Service Collector Triggers (loads all eca.token_data_provider services)<br><br>
↓<br><br>
eca.token_data.current_user Instantiation<br><br>
↓<br><br>
entity_type.manager Instantiation<br><br>
↓<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->token = $token;
}
// Forced to use service locator (workaround):
public function someMethod() {
\Drupal::service('token')->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->tokenDataProviders === null) {
$this->tokenDataProviders = [];
foreach ($this->collectedProviders as $provider) {
// Only load providers that are ready
if ($provider instanceof LazyTokenDataProviderInterface && !$provider->isReady()) {
continue; // Skip providers that aren't ready yet
}
$this->tokenDataProviders[] = $provider;
}
}
return $this->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->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') &&
\Drupal::hasService('cache.entity');
}
/**
* Gets entity type manager, loading it lazily.
*/
protected function getEntityTypeManager(): EntityTypeManagerInterface {
if ($this->entityTypeManager === null) {
$this->entityTypeManager = \Drupal::service('entity_type.manager');
}
return $this->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