Nette DI 3.3: New Phase System for Extensions

David Grudl
Nette Core | 8301
+
+2
-

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.