Inject attribute
Use #[Inject] when ordinary type-based autowiring cannot express the dependency: to force a concrete implementation, mark container-owned wiring for tooling, or inject a property/setter on a class whose constructor you cannot change.
Quick start
use Gacela\Framework\Attribute\Inject;
final class CatalogService
{
public function __construct(
#[Inject] private readonly LoggerInterface $logger,
#[Inject(RedisCache::class)] private readonly CacheInterface $cache,
) {}
}- A bare
#[Inject]resolves the parameter by its type (same as autowiring, but explicit). #[Inject(RedisCache::class)]forces a specific implementation regardless of the global binding.
Gacela\Framework\Attribute\Inject is the preferred 2.0 import. It extends the container attribute, so both imports can coexist while an application migrates.
Property and setter injection
Gacela 2.0 also supports properties and one-argument setter methods. This is useful for vendor or framework classes whose constructor is fixed:
final class CatalogController extends VendorController
{
#[Inject]
private LoggerInterface $logger;
#[Inject(RedisCache::class)]
public function setCache(CacheInterface $cache): void
{
$this->cache = $cache;
}
}Private, protected, and inherited properties work. Constructor injection remains preferable for application-owned classes because dependencies stay visible in the signature.
Readonly, untyped, scalar-typed, and static properties cannot be injected. A promoted property is handled through its constructor parameter and is not injected twice. Property/setter cycles still throw CircularDependencyException.
Resolution order
For a parameter $p on Consumer, the container resolves in this order:
- A runtime override passed to
make()under$p's name. - A named contextual binding:
when(Consumer::class)->needs('$p')->give(...). - The explicit target in
#[Inject(Target::class)]. - The parameter's default value.
- A type-based contextual binding for
Consumer. - A global
addBinding()for the type. - Recursive autowiring when the type is an instantiable class.
DependencyNotFoundExceptionwhen nothing can resolve it.
Defaults win over type bindings
__construct(?Engine $engine = null) resolves to null even when Engine has a global binding, because defaults are checked first. Remove the default or use #[Inject] when the container should fill the parameter. Nullability alone does not produce null: ?Engine $engine without a default still throws if unresolved.
Inspecting with debug:dependencies
The debug:dependencies command tags #[Inject] parameters so you can verify wiring at a glance:
vendor/bin/gacela debug:dependencies App\\Catalog\\CatalogService --tree✓ $logger LoggerInterface (inject)
✓ $cache CacheInterface (inject -> App\Cache\RedisCache)The one-level view describes constructor parameters. --tree follows transitive dependencies using the container's applied bindings and contextual bindings. Each node is marked binding, instance, autowired, or unresolvable; cycles are marked and cut. The command reports broken graphs instead of throwing so it remains useful as a diagnostic.
When to use #[Inject] vs bindings
| Scenario | Approach |
|---|---|
| Global default for an interface | addBinding() in gacela.php |
| One class needs a different implementation | #[Inject(Concrete::class)] on the parameter |
| Multiple classes need the same override | when()->needs()->give() contextual binding |
| Constructor is controlled by a vendor/framework | #[Inject] on a property or setter |
#[Inject] is opt-in. Classes without it continue to resolve through ordinary autowiring and bindings.
Symfony integration
In Symfony apps, the gacela-project/symfony-bridge package routes #[Inject] parameters through Gacela's container via a compiler pass. See Symfony bridge for setup.