Symfony Expert
Overview
Idiomatic Symfony is about letting the container do the wiring and keeping decisions declarative. Autowiring + autoconfiguration + attributes replace almost all hand-written services.yaml; reach for a compiler pass or an explicit tag only when the framework genuinely can't infer the wiring. From the bundle-author seat the bar is higher: your Extension, Configuration tree, and prepend hook are a public contract, so they must validate input and set sane defaults rather than read globals. These are the judgment calls the framework can't make for you.
Quick Reference
| Goal | Do | Avoid |
|---|---|---|
| Wire your own services | autowiring + autoconfiguration (zero config) | hand-listing every service in YAML |
| Pick one of several tagged services | #[AsTaggedItem(index)] + injected iterator/locator |
manual !tagged_iterator plumbing |
| Inject a scalar/env into one service | #[Autowire('%env(...)%')] on the param |
a global parameter just for one consumer |
| Current HTTP request inside a service | inject RequestStack, call getCurrentRequest() |
injecting Request (stale / absent off-request) |
| React to a framework/app event | #[AsEventListener] |
manual kernel.event_listener tag |
| Several events in one class | EventSubscriberInterface |
many separate listeners |
| Bundle user config | Configuration tree + Extension::load |
reading $_ENV in the Extension |
| Set defaults on another bundle | prepend() in your Extension |
telling users to edit their config |
| Twig callable returning markup | is_safe => ['html'] + escape input yourself |
returning raw HTML without is_safe (double-escaped) |
| Pin a component version | extra.symfony.require (needs symfony/flex) |
assuming it works in a flex-less CI matrix |
| HTTP calls | HttpClientInterface (PSR-18 via Psr18Client) |
curl/file_get_contents |
| Modify definitions across bundles | a CompilerPass |
doing it where load order isn't guaranteed |
Core Patterns
The bundle Extension — bridge between user config and the container. The class MUST be named <Alias>Extension; load() validates config against the tree then loads services.php. Turn validated config into parameters or method calls — don't read env vars here. Use prepend() to set defaults on other bundles (Twig paths, an http_client scope) before they load.
$config = $this->processConfiguration(new Configuration(), $configs);
$container->setParameter('acme_payment.api_key', $config['api_key']);
Runnable:
examples/di-extension.php
The Configuration tree — your bundle's validated, self-documenting config contract. Root name MUST equal the Extension alias. isRequired(), cannotBeEmpty(), enumNode()->values(), addDefaultsIfNotSet() give users validation and config:dump-reference output for free.
Runnable:
examples/configuration-tree.php
RequestStack, never Request. Services are shared singletons; the Request is per-request and simply absent in CLI/worker contexts. Injecting Request freezes a stale or missing object. Inject RequestStack and fetch the current request each call, tolerating null. Bind scalars at the injection point with #[Autowire] instead of inventing a global parameter.
public function __construct(
private RequestStack $requestStack,
#[Autowire('%env(string:APP_LOCALE)%')] private string $defaultLocale = 'en',
) {}
Runnable:
examples/request-stack-service.php
Event listeners via attribute. #[AsEventListener(event: KernelEvents::RESPONSE, priority: -10)] on an __invokeable class is autoconfigured — no tag. Subscribe by the KernelEvents constant (typo = fatal, not a silent no-op), and guard with isMainRequest() so sub-requests don't get double-processed. Use a subscriber only when one class owns several events.
Runnable:
examples/event-listener.php
Twig extension with html-safe output. Autoconfiguration tags it automatically. A callable returning markup needs is_safe => ['html'] or Twig double-escapes it — but that makes you responsible for htmlspecialchars-ing any interpolated input. is_safe over unescaped user input is an XSS hole.
new TwigFunction('price_badge', $this->priceBadge(...), ['is_safe' => ['html']]);
Runnable:
examples/twig-extension.php
Tagged services & selection. Tag a family with an interface (autoconfigure maps interface → tag), then inject #[TaggedIterator('app.handler')] or a ServiceLocator. #[AsTaggedItem(index: 'visa', priority: 10)] keys/orders items so you can pick one by name without a compiler pass.
Compiler pass — only when load order matters. Use process(ContainerBuilder) to collect findTaggedServiceIds() and rewire definitions after all extensions have loaded. If the decision is purely config-driven, do it in load() instead — a pass is heavier and runs out of band.
Attribute routing & controllers. #[Route('/pay/{id}', methods: ['POST'])] on the action; type-hint services as action arguments (autowired per-action) and let #[MapRequestPayload] / #[MapQueryString] deserialize+validate input. Controllers extending AbstractController get $this->json(), render(), etc.; a plain invokable controller is fine too.
HttpClient & PSR-18. Inject HttpClientInterface (scoped clients via framework.http_client.scoped_clients, or #[Target('acmePayment.http')]). When a library needs a PSR-18 client, wrap it: new Psr18Client($symfonyHttpClient) — you keep Symfony's retry/profiler and still satisfy the PSR contract.
Common Mistakes
- Injecting
Request,Session, orSecurity's user into a service constructor — all are request-scoped. InjectRequestStack/Securityand read on demand. - Reading
$_ENV/getenv()in a bundle Extension or service — expose it as config or%env()%so it's overridable and cacheable. Env access inload()happens at compile time and gets baked into the cached container. is_safe => ['html']without escaping interpolated input — XSS. Escape everything you didn't generate.- Reaching for a compiler pass for config-driven wiring — if you can decide in
load()from the processed config, do that; passes are for cross-bundle, post-load rewiring. extra.symfony.requireincomposer.jsonto pin a component, but nosymfony/flexinstalled — Flex is what reads that key. In a CI matrix that pins versions without flex, the constraint is silently ignored and you resolve the wrong versions. Either install flex or pin with realrequireconstraints.- Subscribing to an event by string literal — use the
*Eventsconstant; a typo'd string just never fires. - Forgetting
isMainRequest()in kernel.request/response listeners — sub-requests (fragments/ESI/error) re-trigger them. newinstead of the container for things with dependencies — you lose autowiring, decoration, and lazy services.- Mismatched Extension alias and Configuration root — config silently won't bind.
When NOT to over-engineer
A small app does not need a bundle, a compiler pass, or hand-tuned service tags — autowiring + autoconfiguration with services.yaml's default resource: glob covers it. Don't build a Configuration tree for two parameters you could #[Autowire] directly. Write the bundle Extension/Configuration machinery when you're actually distributing reusable code (e.g. a takt-symfony integration bundle), not for app-local services.
Sources
- Service Container · Autowiring ·
#[Autowire]& service attributes - Bundles · Bundle configuration & semantic config ·
prependExtension - Compiler passes · Service tags
- Events & EventDispatcher · Kernel events
- Routing attributes · Twig extensions
- HttpClient & PSR-18 · Symfony Flex &
extra.symfony.require - Configuring with env vars & secrets