A PSR-11 container needs to know how to build every service.
The usual answer is a factory class per service, which means that a project ends up carrying one extra file for every class it wires up.
dot-dependency-injection replaces those files with a single attribute on the constructor and two reusable factories.
Two files, plus the registration.
<?php
declare(strict_types=1);
namespace YourApp\Service;
use Doctrine\ORM\EntityManagerInterface;
use YourApp\Helper\UrlHelper;
use YourApp\Repository\UserRepository;
class UserService
{
public function __construct(
protected UserRepository $userRepository,
protected UrlHelper $urlHelper,
protected array $config,
) {
}
}
<?php
declare(strict_types=1);
namespace YourApp\Service;
use Psr\Container\ContainerInterface;
use YourApp\Helper\UrlHelper;
use YourApp\Repository\UserRepository;
class UserServiceFactory
{
public function __invoke(ContainerInterface $container): UserService
{
return new UserService(
$container->get(UserRepository::class),
$container->get(UrlHelper::class),
$container->get('config')['user'],
);
}
}
'factories' => [
UserService::class => UserServiceFactory::class,
],
#[Inject]One file, and the registration.
<?php
declare(strict_types=1);
namespace YourApp\Service;
use Dot\DependencyInjection\Attribute\Inject;
use YourApp\Helper\UrlHelper;
use YourApp\Repository\UserRepository;
class UserService
{
#[Inject(
UserRepository::class,
UrlHelper::class,
'config.user',
)]
public function __construct(
protected UserRepository $userRepository,
protected UrlHelper $urlHelper,
protected array $config,
) {
}
}
'factories' => [
UserService::class => AttributedServiceFactory::class,
],
Fewer files, less code. A module with 20 services no longer needs 20 factory classes. The wiring shrinks from a class per service to four lines inside the class that already exists.
One source of truth. The dependency list sits directly above the constructor it feeds. Adding a constructor parameter and forgetting to update the factory is the single most common wiring bug in a factory-based project, and it cannot happen when both live on the same lines.
No drift, no duplication.
Hand-written factories repeat the same $container->get(...) pattern hundreds of times across a project.
Every repetition is a place where a typo, a stale service name, or a copy-paste mistake can hide.
Nothing to test. A trivial factory is still code, so it shows up in coverage reports and either gets a test that asserts nothing meaningful or drags coverage down. The two factories in this package are tested once, here.
Configuration without boilerplate.
'config.user' replaces $container->get('config')['user'] and fails with a clear, package-level exception naming the full path when that key is missing, instead of an Undefined array key notice or a silent null.
Repositories for free.
AttributedRepositoryFactory removes an entire category of factories: every Doctrine repository in a project is otherwise a near-identical factory calling $container->get(EntityManagerInterface::class)->getRepository(...).
Consistent failures.
All wiring errors surface as Dot\DependencyInjection\Exception\ExceptionInterface, with messages that name the class, the attribute and the factory involved, rather than whatever each hand-written factory happened to do.
Readable classes. Anyone opening the class sees what it needs and where each dependency comes from without opening a second file.
Being explicit about the trade-offs matters more than the line count.
| Hand-written factory | #[Inject] |
|
|---|---|---|
| Wiring is checked by the type system | yes, new UserService(...) is analysed |
no, names are strings resolved at runtime |
| Order of arguments verified statically | yes | no, the attribute order must match the constructor |
| Reflection at build time | none | one ReflectionClass per service creation, not cached |
| Conditional or computed wiring | anything PHP allows | not supported |
| Works on third-party classes | yes | only on classes you can annotate |
#[Inject] covers the common case: a constructor that receives services and configuration.
Write a factory by hand when you need something else.
Both styles coexist without any friction: the factory is chosen per service in your ConfigProvider, so you can use AttributedServiceFactory for the bulk of a module and a hand-written factory for the few services that need one.