- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You join a Symfony codebase. There's a services.yaml with 340 lines. Half of it registers handlers. New use case? Open the YAML, paste eight lines, hope nothing collides. Every onboarding doc has a section titled "don't forget to register your handler."
That codebase has a missing compiler pass. Once it's there, you write the handler class, and the container finds it. No YAML edit. No bootstrap line. If the handler is malformed, the build fails with a real error instead of a 500 at 3am.
Compiler passes are the feature that quietly separates the senior Symfony devs from everyone else. They're documented, but the docs read like a reference manual and most teams never reach the page. Here's how they actually work, and where they save you 300 lines.
What a compiler pass actually is
Symfony's container has two phases. Build-time: it reads your config, resolves arguments, validates everything, and writes a compiled PHP class to var/cache/. Runtime: that compiled class is what your app talks to. It's just method calls.
A compiler pass is a hook into build-time. You get the raw ContainerBuilder before it's compiled and you can rewrite anything: add definitions, remove definitions, change arguments, tag services, validate the shape of the container, fail the build if something's wrong.
Because it runs once at build, not per request, the cost is zero in production. You can do expensive reflection, scan filesystems, validate interface conformance. None of it shows up in your p99.
The mental model worth keeping: a compiler pass is the place where you encode rules about your container in code, instead of typing the same five YAML lines for every handler.
The tag-and-collect pattern
This is the pattern. Learn it once, use it everywhere.
You tag a set of services with a label (app.use_case, app.event_subscriber, app.payment_gateway). You write a registry service that holds a map of them. The compiler pass connects the two: it finds every tagged service and injects them into the registry.
The reader-friendly part: adding a new tagged thing is just writing the class. Removing one is deleting the class. The registry stays the same. CI catches malformed implementations because the pass validates them.
Here's a real one. An application with use cases: CreateOrder, CancelOrder, RefundPayment, 27 more. The framework needs to dispatch them by name from an HTTP controller.
<?php
// src/UseCase/UseCaseInterface.php
namespace App\UseCase;
interface UseCaseInterface
{
public function execute(object $input): object;
}
<?php
// src/UseCase/CreateOrder.php
namespace App\UseCase;
use App\Domain\Order\OrderRepository;
final class CreateOrder implements UseCaseInterface
{
public function __construct(
private readonly OrderRepository $orders,
) {
}
public function execute(object $input): object
{
// real domain work goes here
return $this->orders->create($input);
}
}
The registry that holds them:
<?php
// src/UseCase/UseCaseRegistry.php
namespace App\UseCase;
use Psr\Container\ContainerInterface;
final class UseCaseRegistry
{
public function __construct(
private readonly ContainerInterface $locator,
) {
}
public function get(string $name): UseCaseInterface
{
if (!$this->locator->has($name)) {
throw new \RuntimeException("Unknown use case: {$name}");
}
return $this->locator->get($name);
}
}
Notice it takes a ContainerInterface. That's a service locator, not the full container. We give it only the use cases. The pass builds that locator.
The compiler pass:
<?php
// src/DependencyInjection/Compiler/UseCaseRegistryPass.php
namespace App\DependencyInjection\Compiler;
use App\UseCase\UseCaseInterface;
use App\UseCase\UseCaseRegistry;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class UseCaseRegistryPass implements CompilerPassInterface
{
public const TAG = 'app.use_case';
public function process(ContainerBuilder $container): void
{
if (!$container->has(UseCaseRegistry::class)) {
return;
}
$refs = [];
foreach ($container->findTaggedServiceIds(self::TAG) as $id => $tags) {
$def = $container->getDefinition($id);
// validate at build time, not at 3am
$class = $container->getParameterBag()->resolveValue($def->getClass());
if (!is_subclass_of($class, UseCaseInterface::class)) {
throw new \LogicException(sprintf(
'Service "%s" is tagged "%s" but does not implement %s.',
$id, self::TAG, UseCaseInterface::class
));
}
// name = first tag attribute, fallback to short class name
$name = $tags[0]['name'] ?? (new \ReflectionClass($class))->getShortName();
$refs[$name] = new Reference($id);
}
$locator = ServiceLocatorTagPass::register($container, $refs);
$container->getDefinition(UseCaseRegistry::class)
->setArgument('$locator', $locator);
}
}
Three things to notice. The pass returns early if the registry isn't defined. Never assume your service exists when running in a partial test container. The interface check fails the build, not the request. And ServiceLocatorTagPass::register() is the under-documented helper that gives you a lazy ContainerInterface of references, so the registry doesn't instantiate every use case on boot.
Register the pass in your kernel:
<?php
// src/Kernel.php
namespace App;
use App\DependencyInjection\Compiler\UseCaseRegistryPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new UseCaseRegistryPass());
}
}
autoconfigureTag: the line that does the tagging for you
You don't want every developer to remember tags: ['app.use_case'] when they add a class. You want the tag applied automatically because the class implements UseCaseInterface. That's autoconfigureTag.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/{Kernel.php,Entity,DependencyInjection}'
Then in a bundle extension or directly in your kernel's build():
<?php
// src/Kernel.php
use App\UseCase\UseCaseInterface;
protected function build(ContainerBuilder $container): void
{
$container->registerForAutoconfiguration(UseCaseInterface::class)
->addTag(UseCaseRegistryPass::TAG);
$container->addCompilerPass(new UseCaseRegistryPass());
}
Now any class implementing UseCaseInterface is automatically tagged, automatically picked up by the pass, automatically added to the registry. Onboarding doc shrinks from a page to one line: write a class that implements UseCaseInterface. That's it.
Debugging: debug:container is your best friend
Compiler passes are invisible at runtime. You can't dd($container) mid-request and see what they did. The way you debug them is the CLI.
# show every service tagged "app.use_case"
php bin/console debug:container --tag=app.use_case
Output (truncated):
Service ID Class
App\UseCase\CreateOrder App\UseCase\CreateOrder
App\UseCase\CancelOrder App\UseCase\CancelOrder
App\UseCase\RefundPayment App\UseCase\RefundPayment
...
If a class is missing from this list, your autoconfigureTag isn't wired or the class isn't in a path the container scans. Both are diagnosable in 30 seconds.
To see the locator the pass built:
php bin/console debug:container UseCaseRegistry
You'll get the resolved ServiceLocator argument with the full map of references. If the registry's $locator argument is empty, the pass didn't run. Usually because you forgot the addCompilerPass() call in build().
For the deep case where you suspect the compiled container itself is wrong, dump it:
# the compiled container lives here
ls var/cache/dev/App_KernelDevDebugContainer.php
Open it. It's generated PHP, ~10k lines, but it's grep-able. Search for UseCaseRegistry and you'll see the exact constructor arguments the pass produced. This is the source of truth for what your container actually does.
When autoconfigure is enough, and when it isn't
Autoconfigure handles "tag every implementation of this interface". For 70% of cases, that's all you need. You don't need a custom pass to just register subscribers. Symfony already ships passes that do this for EventSubscriberInterface, MessageHandlerInterface, and a dozen others.
You reach for a custom pass when you need to do something with the tags: collect them into a registry, sort them, validate them, inject the map into one consumer. The DI container doesn't know what your registry needs. You teach it.
A useful rule: if the answer is "just tag this and the framework will use it", you don't need a pass. If the answer is "tag these and aggregate them into something", you need a pass.
Pass priorities: the gotcha that catches everyone
This is the part nobody warns you about until you've hit it.
Compiler passes run in a specific order, controlled by priorities and types. Priority is an integer (higher = earlier). Type is an enum: BEFORE_OPTIMIZATION, OPTIMIZATION, BEFORE_REMOVING, REMOVING, AFTER_REMOVING. The default is BEFORE_OPTIMIZATION with priority 0.
Why this matters: if you have two passes that both touch the same definition, the second pass overwrites the first. If your pass runs after Symfony's AutowirePass, the arguments you set might be re-resolved. If it runs before RemoveUnusedDefinitionsPass, your tagged service might get removed because nothing references it yet, and your pass is what was going to reference it.
The fix is being explicit:
<?php
// src/Kernel.php
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(
new UseCaseRegistryPass(),
PassConfig::TYPE_BEFORE_OPTIMIZATION,
priority: 100 // higher = runs earlier
);
}
A concrete pain pattern: you write a pass that tags services based on a custom attribute, and a second pass that collects them. The second pass runs first because both default to priority 0, alphabetical tie-break, and finds nothing. Build succeeds. Production is broken. Debug time: hours.
Fix: give the tagging pass a higher priority than the collecting pass. Or merge them. Or move the tagging to BEFORE_OPTIMIZATION and the collecting to OPTIMIZATION. The point is being explicit about ordering when you have more than one pass that touches the same services.
A second priority gotcha: third-party bundles add their own passes. If your bundle conflicts with Doctrine's DoctrineOrmMappingsPass (which runs at priority 0, BEFORE_OPTIMIZATION), you'll silently overwrite or be overwritten. Run php bin/console debug:container --env=dev with the -vv flag and the framework will dump pass execution order in the cache build logs.
The senior move: when you write a pass, document the priority and type in a comment at the top. The next person debugging a "why isn't my service registered" issue will thank you.
/**
* @priority TYPE_BEFORE_OPTIMIZATION, 100 (must run before our DefaultArgumentsPass at 50)
*/
final class UseCaseRegistryPass implements CompilerPassInterface
{
// ...
}
What this replaces
Without the pass, registering 30 use cases looks like 240 lines of YAML, one block per handler, plus a manually-maintained dispatcher class that grows every time someone adds a use case. Every addition is a four-file edit: write the class, update the YAML, update the dispatcher, write the test for the dispatcher.
With the pass, it's one file: the use case class. The YAML doesn't change. The dispatcher doesn't change. CI validates that the class implements the interface, that no two use cases collide on name, that the registry got its locator. The thing that took ten minutes per new feature takes thirty seconds.
The same pattern works for event subscribers (Symfony ships this one), payment gateways, notification channels, importers, exporters, command handlers, query handlers, validation rules, and anywhere else your app has a "plug-in" shape. Tag, collect, validate, inject. Once you've written one, you'll see them everywhere your codebase has manual registration boilerplate.
What's the worst manual-registration YAML block in your current Symfony app? Drop it in the comments and we can talk through whether a pass would kill it.
If this was useful
The compiler pass pattern is one of the small things that compounds over the life of a Symfony app: fewer YAML edits, fewer onboarding bugs, fewer 3am pages because someone forgot to register a handler. It's also the kind of pattern that earns its keep more once you've drawn architectural boundaries around the rest of your codebase. If you want the bigger picture of how to keep PHP applications clean as they grow (domain layer, use case layer, infrastructure layer, the parts the framework shouldn't own), Decoupled PHP is the book I wrote for that.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)