ahmed-bhs/hexagonal-maker-bundle
In hexagonal architecture, Dependency Injection (DI) is critical for binding ports (interfaces) to adapters (implementations). Symfony's DI container handles this wiring automatically with proper configuration.
// ❌ Without DI: Hard-coded dependency
class RegisterUserHandler
{
public function __invoke(RegisterUserCommand $command): void
{
$repository = new DoctrineUserRepository(); // ❌ Tight coupling!
// ...
}
}
// ✅ With DI: Injected dependency
class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $users // ✅ Interface, not implementation
) {}
public function __invoke(RegisterUserCommand $command): void
{
$this->users->save(...); // Uses whatever implementation is configured
}
}
DI Container Configuration: Tells Symfony "when someone needs UserRepositoryInterface, give them DoctrineUserRepository".
Autowiring: Symfony automatically resolves constructor dependencies by looking at type hints.
// Handler with type-hinted dependencies
class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $users, // Autowired
private EmailSenderInterface $emailSender, // Autowired
private EventDispatcherInterface $events, // Autowired
) {}
}
Symfony sees these type hints and automatically provides the correct services.
In config/services.yaml:
services:
_defaults:
autowire: true # Enable autowiring
autoconfigure: true # Automatically configure services (tags, etc.)
public: false # Services are private by default
# Auto-register all classes in src/ as services
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/*/Domain/Model/' # Exclude entities
- '../src/*/Domain/ValueObject/' # Exclude value objects
- '../src/Kernel.php'
How it works:
src/ directoryProblem: Interface cannot be instantiated—Symfony needs to know which implementation to use.
// Handler needs UserRepositoryInterface
class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $users // ❌ Interface, cannot be instantiated!
) {}
}
Solution: Bind interface to implementation in services.yaml.
services:
# Bind interface → implementation
App\User\Domain\Port\UserRepositoryInterface:
class: App\User\Infrastructure\Persistence\DoctrineUserRepository
Explanation:
UserRepositoryInterfaceDoctrineUserRepositoryservices:
# Implementation
App\User\Infrastructure\Persistence\DoctrineUserRepository:
# Autowired by default
# Alias: interface → implementation
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\DoctrineUserRepository
Benefits:
services:
_defaults:
bind:
# Automatically bind this interface to this implementation
# for ALL services
App\User\Domain\Port\UserRepositoryInterface: '[@App](https://github.com/App)\User\Infrastructure\Persistence\DoctrineUserRepository'
When to use: Interface used in many places, avoid repeating configuration.
config/services.yaml)services:
_defaults:
autowire: true
autoconfigure: true
# Register all implementations
App\User\Infrastructure\Persistence\DoctrineUserRepository:
App\User\Infrastructure\Persistence\InMemoryUserRepository:
# Default binding (production)
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\DoctrineUserRepository
config/services_test.yaml)services:
# Override binding for test environment
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\InMemoryUserRepository
Result:
bin/phpunit → uses InMemoryUserRepositoryDoctrineUserRepositoryconfig/services_dev.yaml)# Optional: use in-memory for fast dev feedback
services:
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\InMemoryUserRepository
# Or enable debug logging
App\User\Infrastructure\Persistence\DoctrineUserRepository:
decorates: App\User\Infrastructure\Persistence\DoctrineUserRepository
arguments:
$decorated: '@.inner'
$logger: '[@logger](https://github.com/logger)'
# config/services.yaml (default: production)
services:
# Production: real SMTP
App\Notification\Domain\Port\EmailSenderInterface:
alias: App\Notification\Infrastructure\Email\SymfonyEmailSender
# config/services_test.yaml
services:
# Test: in-memory fake
App\Notification\Domain\Port\EmailSenderInterface:
alias: App\Notification\Infrastructure\Email\InMemoryEmailSender
# config/services_dev.yaml
services:
# Dev: log emails instead of sending
App\Notification\Domain\Port\EmailSenderInterface:
alias: App\Notification\Infrastructure\Email\LoggingEmailSender
Example: Multiple event subscribers for the same event.
interface EventSubscriberInterface
{
public function handle(DomainEvent $event): void;
}
class SendEmailSubscriber implements EventSubscriberInterface { /* ... */ }
class LogEventSubscriber implements EventSubscriberInterface { /* ... */ }
class UpdateCacheSubscriber implements EventSubscriberInterface { /* ... */ }
Need: Inject all implementations, not just one.
services:
# Tag each implementation
App\Notification\Infrastructure\Event\SendEmailSubscriber:
tags: ['app.event_subscriber']
App\Notification\Infrastructure\Event\LogEventSubscriber:
tags: ['app.event_subscriber']
App\Notification\Infrastructure\Event\UpdateCacheSubscriber:
tags: ['app.event_subscriber']
services:
# Event dispatcher receives all subscribers
App\Shared\Infrastructure\Event\EventDispatcher:
arguments:
$subscribers: !tagged_iterator app.event_subscriber
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
class EventDispatcher implements EventDispatcherInterface
{
public function __construct(
#[TaggedIterator('app.event_subscriber')]
private iterable $subscribers // All tagged services injected here
) {}
public function dispatch(DomainEvent $event): void
{
foreach ($this->subscribers as $subscriber) {
$subscriber->handle($event);
}
}
}
Automatic tagging: Tag all classes implementing an interface.
services:
_instanceof:
# Automatically tag all classes implementing EventSubscriberInterface
App\Shared\Domain\Event\EventSubscriberInterface:
tags: ['app.event_subscriber']
Now you don't need to manually tag each implementation!
services:
_instanceof:
# Auto-tag all query handlers
App\Shared\Application\Query\QueryHandlerInterface:
tags: ['app.query_handler']
# Query bus receives all handlers
App\Shared\Infrastructure\Query\QueryBus:
arguments:
$handlers: !tagged_iterator app.query_handler
class QueryBus
{
public function __construct(
#[TaggedIterator('app.query_handler')]
private iterable $handlers
) {}
public function dispatch(Query $query): mixed
{
foreach ($this->handlers as $handler) {
if ($handler->supports($query)) {
return $handler->handle($query);
}
}
throw new NoHandlerFoundException();
}
}
Example: Add caching to repository without changing repository code.
namespace App\User\Infrastructure\Persistence;
final readonly class CachedUserRepository implements UserRepositoryInterface
{
public function __construct(
private UserRepositoryInterface $decorated, // Original repository
private CacheInterface $cache,
) {}
public function findById(UserId $id): ?User
{
return $this->cache->get(
"user:{$id}",
fn() => $this->decorated->findById($id) // Delegate to original
);
}
public function save(User $user): void
{
$this->decorated->save($user);
$this->cache->delete("user:{$user->getId()}"); // Invalidate cache
}
}
services:
# Original repository
App\User\Infrastructure\Persistence\DoctrineUserRepository:
# Decorator wraps original
App\User\Infrastructure\Persistence\CachedUserRepository:
decorates: App\User\Infrastructure\Persistence\DoctrineUserRepository
arguments:
$decorated: '@.inner' # @.inner = the decorated service
Result:
DoctrineUserRepository gets CachedUserRepositoryCachedUserRepository wraps DoctrineUserRepositoryMultiple decorators:
services:
App\User\Infrastructure\Persistence\DoctrineUserRepository:
# First decorator: caching
App\User\Infrastructure\Persistence\CachedUserRepository:
decorates: App\User\Infrastructure\Persistence\DoctrineUserRepository
decoration_priority: 10 # Higher priority = outer layer
arguments:
$decorated: '@.inner'
# Second decorator: logging
App\User\Infrastructure\Persistence\LoggingUserRepository:
decorates: App\User\Infrastructure\Persistence\DoctrineUserRepository
decoration_priority: 5 # Lower priority = inner layer
arguments:
$decorated: '@.inner'
Call chain:
Handler → LoggingUserRepository → CachedUserRepository → DoctrineUserRepository → Database
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
# Auto-register all services
App\:
resource: '../src/'
exclude:
- '../src/*/Domain/Model/'
- '../src/*/Domain/ValueObject/'
- '../src/Kernel.php'
# Bind ports to adapters
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\DoctrineUserRepository
App\User\Domain\Port\PasswordHasherInterface:
alias: App\User\Infrastructure\Security\SymfonyPasswordHasher
App\Shared\Domain\Port\EmailSenderInterface:
alias: App\Shared\Infrastructure\Email\SymfonyEmailSender
App\Shared\Domain\Port\EventDispatcherInterface:
alias: App\Shared\Infrastructure\Event\SymfonyEventDispatcher
services:
# Write side
App\Order\Domain\Port\OrderRepositoryInterface:
alias: App\Order\Infrastructure\Persistence\DoctrineOrderRepository
# Read side (CQRS)
App\Order\Application\Query\OrderQueryInterface:
alias: App\Order\Infrastructure\Query\SqlOrderQuery
# External services
App\Order\Domain\Port\PaymentProcessorInterface:
alias: App\Order\Infrastructure\Payment\StripePaymentProcessor
App\Order\Domain\Port\InventoryServiceInterface:
alias: App\Order\Infrastructure\Inventory\HttpInventoryService
services:
# Tenant resolver
App\Shared\Infrastructure\Tenancy\TenantResolver:
# Tenant-aware repository
App\User\Infrastructure\Persistence\TenantAwareUserRepository:
arguments:
$tenantResolver: '[@App](https://github.com/App)\Shared\Infrastructure\Tenancy\TenantResolver'
# Bind interface to tenant-aware implementation
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\TenantAwareUserRepository
services:
# Base repository
App\Product\Infrastructure\Persistence\DoctrineProductRepository:
# Decorator: caching
App\Product\Infrastructure\Persistence\CachedProductRepository:
decorates: App\Product\Infrastructure\Persistence\DoctrineProductRepository
decoration_priority: 10
arguments:
$decorated: '@.inner'
$cache: '[@cache](https://github.com/cache).app'
# Decorator: logging
App\Product\Infrastructure\Persistence\LoggingProductRepository:
decorates: App\Product\Infrastructure\Persistence\DoctrineProductRepository
decoration_priority: 5
arguments:
$decorated: '@.inner'
$logger: '[@logger](https://github.com/logger)'
# Bind interface to base (decorators wrap it automatically)
App\Product\Domain\Port\ProductRepositoryInterface:
alias: App\Product\Infrastructure\Persistence\DoctrineProductRepository
Call chain: Handler → Logging → Caching → Doctrine → DB
Error:
Cannot autowire service "App\User\Application\Handler\RegisterUserHandler":
argument "$users" of method "__construct()" is type-hinted with the interface
"App\User\Domain\Port\UserRepositoryInterface" but no implementation is registered.
Solution: Bind interface to implementation.
services:
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\DoctrineUserRepository
Error:
Service "App\User\Infrastructure\Persistence\DoctrineUserRepository" not found.
Solution: Check that directory is not excluded in services.yaml.
services:
App\:
resource: '../src/'
exclude:
- '../src/*/Domain/Model/' # ✅ Exclude entities
# ❌ Don't exclude Infrastructure!
Error:
Circular reference detected for service "App\User\Infrastructure\Persistence\DoctrineUserRepository".
Solution: Refactor to remove circular dependency or use setter injection.
services:
App\User\Infrastructure\Persistence\DoctrineUserRepository:
calls:
- setLogger: ['[@logger](https://github.com/logger)'] # Setter injection instead of constructor
Problem: Test uses production implementation instead of fake.
Solution: Create config/services_test.yaml and override binding.
# config/services_test.yaml
services:
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\InMemoryUserRepository
Error: $subscribers is empty even though services are tagged.
Solution: Check tag name matches.
services:
# Tag definition
_instanceof:
App\Shared\Domain\Event\EventSubscriberInterface:
tags: ['app.event_subscriber'] # Tag name
# Injection (must match!)
App\Shared\Infrastructure\Event\EventDispatcher:
arguments:
$subscribers: !tagged_iterator app.event_subscriber # Same tag name
✅ GOOD:
App\User\Domain\Port\UserRepositoryInterface:
alias: App\User\Infrastructure\Persistence\DoctrineUserRepository
❌ AVOID:
App\User\Domain\Port\UserRepositoryInterface:
class: App\User\Infrastructure\Persistence\DoctrineUserRepository
Reason: Aliases are clearer and easier to override.
config/
├── services.yaml # Default (production)
├── services_dev.yaml # Development overrides
├── services_test.yaml # Test overrides
└── services_prod.yaml # Production-specific (optional)
_instanceof for Auto-Tagging✅ GOOD:
_instanceof:
App\Shared\Application\Query\QueryHandlerInterface:
tags: ['app.query_handler']
❌ AVOID:
App\User\Application\Query\FindUserQueryHandler:
tags: ['app.query_handler']
App\Order\Application\Query\FindOrderQueryHandler:
tags: ['app.query_handler']
# ... manually tag each one
App\:
resource: '../src/'
exclude:
- '../src/*/Domain/Model/' # Entities
- '../src/*/Domain/ValueObject/' # Value objects
- '../src/*/Application/Command/' # DTOs
- '../src/*/Application/Query/' # DTOs
- '../src/Kernel.php'
Reason: Only services should be registered, not data objects.
| Task | Configuration |
|---|---|
| Bind port to adapter | alias: App\...\Implementation |
| Inject all tagged services | !tagged_iterator tag_name |
| Auto-tag by interface | _instanceof: { Interface: tags: [...] } |
| Override for test | Create services_test.yaml |
| Decorate service | decorates: OriginalService + $decorated: '@.inner' |
| Exclude directory | exclude: ['../src/Path/'] |
How can I help you explore Laravel packages today?