Nette DI 3.3: New Phase System for Extensions

- David Grudl
- Nette Core | 8301
15 years ago, when I was writing the first version of the DI Compiler,
I needed to somehow solve the order in which extensions are executed.
That's when three methods were born: loadConfiguration(),
beforeCompile(), and afterCompile(). And the order of
their execution? Simply in the order you register the extensions. Done. Here's the
original commit.
I knew it was just temporary. That it would soon need to be solved properly. But… as it often goes, temporary solutions last the longest 😅

Problems with the Current State
1. One registration = order for all phases
When I register an extension as third, its loadConfiguration()
runs third, beforeCompile() also third, afterCompile()
also third. But different phases may need different ordering!
2. Implicit dependencies
Extensions happily call getByType(), findByType()
and hope that the service already exists. Nowhere does it say “hey, I need
ApplicationExtension to have already run”. It either works or it doesn't.
3. Hardcoded hacks in Compiler
Take a look at Compiler::processExtensions(). There's this
beauty:
// InjectExtension is moved to the end
$last = $this->getExtensions(InjectExtension::class);
$this->extensions = array_merge(array_diff_key($this->extensions, $last), $last);
// SearchExtension is inserted before DecoratorExtension
if ($decorator = $this->getExtensions(DecoratorExtension::class)) {
Arrays::insertBefore($this->extensions, key($decorator),
$this->getExtensions(SearchExtension::class));
}
Every new extension that needs special ordering = another hack in the Compiler. Unscalable, fragile, ugly.
The Road to Hell: Numeric Priorities
Someone might say: “Give each extension a priority, like 10, 20, 30…”
NO! This is an absolutely reliable road to hell. You know it
from CSS z-index: 99999. Or from event listeners. Or from WordPress
(well, when I last saw it 20 years ago, it was there). Priorities are an
anti-pattern that eventually leads to unmaintainable chaos.
The New Solution
I'm proposing a system inspired by how tags and compiler passes already work in Latte. Elegant, declarative, extensible.
Compilation Phases
Instead of three methods (loadConfiguration,
beforeCompile, afterCompile) we have 5 semantic
phases:
enum Phase: string
{
case Setup = 'setup'; // Environment preparation (parameters, extension registration)
case Register = 'register'; // Unconditional service registration
case Discover = 'discover'; // Discovery and conditional registration
case Modify = 'modify'; // Modification of existing services
case Compile = 'compile'; // Modification of the generated class
}
Declarative Dependencies
You can create as many handlers as you want and name them however you like, nicely and descriptively. And each handler can declare before whom or after whom it wants to run:
use Nette\DI\Attributes\Hook;
use Nette\DI\Phase;
class ApplicationExtension extends CompilerExtension
{
#[Hook(Phase::Register)]
public function doRegisterServices(ContainerBuilder $builder): void
{
// Registration of basic services - no dependencies
}
#[Hook(Phase::Discover, before: SearchExtension::class)]
public function doDiscoverPresenters(ContainerBuilder $builder): void
{
// I want to run before SearchExtension, because I know
// presenters better than generic auto-discovery
}
#[Hook(Phase::Modify, before: InjectExtension::class)]
public function doTagPresenters(ContainerBuilder $builder): void
{
// I'm adding the nette.inject tag, so I must run
// before InjectExtension, which processes it
}
}
Special Values
#[Hook(Phase::Modify, after: '*')] // Run last
#[Hook(Phase::Register, before: '*')] // Run first
Manual Registration
Those who don't want attributes can register manually:
public function register(): void
{
$this->hook(Phase::Register, $this->doSomething(...));
$this->hook(Phase::Modify, $this->doOther(...), before: InjectExtension::class);
}
Why Is It 100% Backward Compatible?
The default implementation of register() automatically registers
the old methods:
protected function registerLegacyHandlers(): void
{
$this->hook(Phase::Register, $this->loadConfiguration(...));
$this->hook(Phase::Modify, $this->beforeCompile(...));
$this->hook(Phase::Compile, $this->afterCompile(...));
}
All existing extensions will work without any changes. You can gradually migrate them to the new system, or leave them as they are. And I probably don't need to add that the best tool for migration is Claude Code :-)
Determinism
For each phase, topological sorting is performed. Handlers without mutual dependencies are sorted alphabetically by extension class name. No randomness, predictable behavior.
Cyclic dependencies (A before B, B before A) throw an exception with a description of the cycle.
One more thing…
I've wanted to add auto-discovery of extensions from Composer packages to Nette for ages.
Imagine: you install composer require nette/database and the
extension is automatically registered. No manual extensions: in
config. It just works.
But… auto-discovery is only as good as how well extension ordering is solved. When extensions appear “from nowhere”, you can't rely on registration order. You need a system where each extension declares what it needs.
And that's exactly what we now have!
How it works:
The package author adds to composer.json:
{
"extra": {
"nette": {
"di-extensions": {
"database": "Nette\\Bridges\\DatabaseDI\\DatabaseExtension",
}
}
}
}
Bootstrap reads vendor/composer/installed.json when creating the
container, finds all extensions, and registers them. Thanks to the new phase
system, the order in which they appear doesn't matter – each extension has
its own before/after declarations.
I'd love to hear your feedback! Can you think of other use-cases? Something this system wouldn't be able to solve? Let me know.