teknoo/bridge-phpdi-symfony
Bridges PHP-DI and Symfony, enabling Symfony applications to use PHP-DI’s dependency injection container for service definitions, autowiring, and container interoperability. Helpful for projects adopting PHP-DI or migrating DI setup while keeping Symfony conventions.
Install
composer require teknoo/bridge-phpdi-symfony
Constraints to check before you commit to it: php-di/php-di ^7.1.1, php-di/invoker ^2.2.0, symfony/{dependency-injection,http-kernel,config} at ^6.4.24 || ^7.4 || ^8.1, and PHP 8.4+. The PHP 8.4 floor is a hard blocker on older stacks.
Register the bundle in config/bundles.php (the bundle class lives in src/; open that directory first if the class name isn't obvious from the README):
return [
// ...
Teknoo\DI\SymfonyBridge\BridgeBundle::class => ['all' => true],
];
Write PHP-DI definitions as ordinary PHP-DI 7 config files:
<?php
// config/di/services.php
use function DI\create;
use function DI\get;
return [
'app.mailer' => create(App\Mailer::class)->constructor(get('app.transport')),
'app.transport' => create(App\Transport::class),
App\Mailer::class => get('app.mailer'), // becomes a Symfony alias
];
Point the bundle at them
# config/packages/di_bridge.yaml
di_bridge:
definitions:
- '%kernel.project_dir%/config/di/services.php'
compilation_path: '%kernel.cache_dir%/phpdi'
enable_cache: false
import:
- 'logger' # Symfony service ids to alias into PHP-DI
First useful use case: you have a library or legacy module already wired with PHP-DI, and you want it inside a normal Symfony app without a custom Kernel. Put the PHP-DI definitions in the config above, then inject the result wherever you like — each PHP-DI entry is exported as a Symfony service whose factory is the bridge, so a controller can just type-hint it:
final class SignupController
{
public function __construct(private App\Mailer $mailer) {}
}
Where to look first (in this order):
src/DependencyInjection/Configuration.php — the authoritative config tree; don't trust examples over this file.src/Container/BridgeBuilder.php — the compile-time logic that actually does the export.tests/FunctionalTest/Fixtures/config/ — real, working YAML + di*.php pairs; the fastest way to see a valid setup.Symfony-first, PHP-DI for a legacy module. Keep controllers, commands and listeners as normal Symfony services. Confine PHP-DI to the subsystem that already uses it. Resist the temptation to define everything in PHP-DI — you lose Symfony's autoconfiguration, tagged collectors, and debug:container legibility for anything defined there.
Reverse direction with import. PHP-DI definitions can't see Symfony services by default. di_bridge.import creates aliases in the PHP-DI container pointing at Symfony entries. So a PHP-DI service that needs the Symfony logger:
di_bridge:
import:
- 'logger'
- 'doctrine.orm.entity_manager'
// config/di/services.php — now resolvable
'legacy.reporter' => create(App\Reporter::class)->constructor(get('logger')),
Import aggressively but narrowly: every imported id is one more coupling point between the two containers.
Parameters flow one way. Symfony parameters are exposed to PHP-DI automatically, so get('kernel.environment') or %kernel.debug%-style values work inside definitions. The reverse is not true — a PHP-DI entry that is a scalar gets exported as a Symfony parameter, but don't build a dependency graph on that; treat scalars in PHP-DI as configuration, not as a shared parameter bus.
Ordered definitions for environment overrides. Each di_bridge.definitions entry accepts a priority; highest loads first. Use this for a clean prod/dev split instead of duplicating files:
di_bridge:
definitions:
- { path: '%kernel.project_dir%/config/di/services.php', priority: 10 }
- { path: '%kernel.project_dir%/config/di/services.%kernel.environment%.php', priority: 20 }
Note that highest loads first, which is the opposite of what most people assume about priority numbers — verify against BridgeBuilder before you rely on override semantics.
Extensions for cross-cutting bridge setup. Implement Teknoo\DI\SymfonyBridge\Extension\ExtensionInterface and register it in di_bridge.extensions either by class name or by Symfony service id (with optional priority). Good uses: adding a definitions directory based on a feature flag, bulk-aliasing a set of Symfony ids into PHP-DI, or asserting that required parameters exist. Use an extension rather than a compiler pass — the bridge gives you the PHP-DI builder at the right moment.
Prefer the service-id form of extensions when the extension itself has dependencies. A class-name extension is instantiated directly, so it can't take constructor arguments from the container. If it needs the container, register it as a Symfony service and reference the id.
Compile once, ship the cache. Set compilation_path inside %kernel.cache_dir% and let your deploy run cache:clear/cache:warmup. In dev, this also gives you a single directory to delete when things look stale.
Testing approach. Because the export happens at Symfony compile time, unit-testing the bridge in isolation is mostly futile. Follow the package's own model: boot a real kernel in a functional test (AbstractFunctionalTests::createKernel('file.yml') in this repo's tests is a good template), assert on the resolved services, and clear the cache between runs. If you need a test double for a PHP-DI entry, add a higher-priority definitions file for test rather than mocking the container.
Feature-flag by swapping definitions files, not by conditionally registering the bundle. The bundle's whole contract is compile-time; toggling it at runtime leaves you with a stale compiled container.
Everything is resolved at container compile time. Editing a di.php file without clearing the Symfony cache does nothing. If a change "isn't taking effect," it's almost always the cache. In dev this is painful enough that you should wire cache:clear into your watch loop.
Two caches, not one. Symfony's container cache and (when enable_cache: true) PHP-DI's definitions cache are separate. Clear both. The PHP-DI cache also sits behind APCu.
enable_cache requires APCu. On CLI — which includes bin/console and every test run — you need apc.enable_cli=1 or the cache quietly does nothing (the package's own tests are skipped without it). In a container or CI image without APCu, leave enable_cache: false rather than debugging phantom behaviour.
Beware duplicate instances. If a class is registered both as a Symfony service and as a PHP-DI entry, you get two objects with separate state. Singleton assumptions break in ways that are very hard to trace. Pick one container per class and, where a boundary must be crossed, use di_bridge.import to alias rather than redefining.
Exported services are private. They're normal Symfony services, so $container->get('app.mailer') from a controller will fail in Symfony 6+/7+. Inject them. For inspection use bin/console debug:container --show-private and look for services whose factory is the bridge.
Id collisions are silent-ish. A PHP-DI entry whose id matches an existing Symfony service id is a conflict resolved by load order, not by an error you'll necessarily notice. Namespace your PHP-DI ids (app.*, legacy.*) and keep class-name ids for the aliases.
PHP-DI autowiring can flood the container. If you lean on PHP-DI's autowiring, every autowired entry becomes a Symfony definition. On a large codebase this inflates the compiled container and slows warmup. Define explicitly for anything crossing into Symfony; let autowiring stay internal to PHP-DI.
Values that can't resolve at compile time. Closures and environment-dependent factories are the usual source of "works in PHP-DI alone, fails through the bridge." The bridge has to extract definitions (extractDefinition()) to export them. If a definition can't be extracted, it can't be exported — keep runtime-dependent construction inside the factory body, not in the definition itself.
Extensions-by-service-id depend on prepend() timing. Symfony hands load() a throwaway container, so the extension resolves service-id-declared extensions during prepend() against the real application container. Consequence: the extension service must already be registered at that point. Register it from your own bundle's prepend()/extension, not from services.yaml loaded later, or the bridge will silently miss it.
compilation_path must be writable at container build time. On read-only filesystems (some serverless/PaaS targets) omit it and accept the in-memory path, or pre-warm the cache during the build step.
Don't build on internals. Bridge, BridgeBuilder, Container, CompiledContainer and ContainerDefinitionTrait are implementation. The stable surface is: the di_bridge config keys, the ExtensionInterface, and the exported Symfony services. With two GitHub stars and a single active maintainer, pin an exact version in composer.json and read the src/ you depend on before upgrading.
Debugging checklist, in order: (1) clear both caches; (2) debug:container --show-private | grep for the entry; (3) confirm the definitions file is in di_bridge.definitions with the priority you expect; (4) confirm the entry is extractable (not a raw closure); (5) confirm it hasn't been shadowed by a same-id Symfony service; (6) only then read BridgeBuilder.
APCu + Xdebug in CI. The package's own make test needs both extensions. Expect your CI image to need apcu and apc.enable_cli=1 if you mirror their test setup; otherwise run --no-coverage without Xdebug and skip the cache-dependent tests.
How can I help you explore Laravel packages today?