Short answers to "how do I do X". Every recipe here was executed against the current build before being written down.
Bind the fake over the real one. Nothing in production wiring changes.
$container = new Container([
MailerInterface::class => InMemoryMailer::class,
]);
$service = $container->get(OrderService::class);For a single test, bind() after construction reads better:
$container = new Container();
$container->bind(MailerInterface::class, InMemoryMailer::class);Scalars cannot be autowired. Bind them by parameter name, scoped to the class that needs them:
$container->when(ApiClient::class)
->needs('$timeout')
->give(30);
$container->when(ApiClient::class)
->needs('$baseUrl')
->give('https://api.example.com');$container->when(OrderReport::class)
->needs(StorageInterface::class)
->give(S3Storage::class);
$container->when(TempExport::class)
->needs(StorageInterface::class)
->give(LocalStorage::class);The Symfony answer to this is a compiler pass. There is nothing to pass over here — an autowiring container has no definition set until things resolve — so the answer is plainer: a package exposes definitions, and the application loads them.
// vendor/acme/mailer/config/container.php
return [
Acme\Mailer\TransportInterface::class => Acme\Mailer\SmtpTransport::class,
'acme.mailer' => ['singleton' => Acme\Mailer\Mailer::class, 'tags' => ['notifiers']],
];$container->loadFile(__DIR__ . '/../vendor/acme/mailer/config/container.php');
$container->loadFile(__DIR__ . '/config/services.php'); // yours winsLater keys override earlier ones, so the application always gets the last word, and load order is the whole precedence rule — no priority integers, no pass ordering. A package needing logic rather than data exposes a function instead:
return static function (Container $container): void {
$container->bind(TransportInterface::class, SmtpTransport::class);
$container->when(Mailer::class)->needs('$retries')->give(3);
};This is explicit, ordered and debuggable — you can var_dump the array — which
a pass that rewrites a definition set behind you is not. See
definitions.
Tag the implementations, then resolve them together. tagged() is lazy — it
resolves each service as you iterate, in registration order.
$container->bind(SlackNotifier::class, SlackNotifier::class);
$container->bind(EmailNotifier::class, EmailNotifier::class);
$container->tag([SlackNotifier::class, EmailNotifier::class], 'notifiers');
foreach ($container->tagged('notifiers') as $notifier) {
$notifier->send($message);
}Same mechanism, keyed — a command bus or router needs the handler for a message type, not all of them. Only the one asked for is built:
$container->tag([
'order.placed' => OrderPlacedHandler::class,
'order.shipped' => OrderShippedHandler::class,
], 'handlers');
$container->taggedByKey('handlers', $message->type())->handle($message);An unknown key throws, naming the keys that exist. Use taggedKeys('handlers')
when a message having no handler is a normal outcome rather than a
misconfiguration. See keyed tags.
extend() wraps a single binding. It works even before the service is defined —
the extension is scheduled and applied when the service is set.
$container->set('http.client', new GuzzleClient());
$container->extend('http.client', static function (object $client): object {
return new LoggingClient($client);
});When you want a hook rather than a wrapper:
$container->afterResolving(Connection::class, static function (object $connection): void {
$connection->setTimezone('UTC');
});$container->singleton(Connection::class);Or declare it on the class, so every container shares the intent:
use Gacela\Container\Attribute\Singleton;
#[Singleton]
final class Connection {}The opposite — a fresh instance every time — is #[Factory].
Under Swoole, RoadRunner or FrankenPHP the process outlives the request, so a
singleton() is shared by every request rather than scoped to one. Use a
scope for the per-request half:
$app = new Container($bindings);
$app->singleton(Database::class); // one instance for the whole process
$app->warmUp([Router::class]); // reflection done once, at boot
foreach ($server->requests() as $request) {
$scope = $app->createScope();
$scope->set(Request::class, $request);
$handler->handle($scope);
// $scope goes out of range here, and takes its instances with it
}The scope resolves anything it does not hold through $app, and nothing is
re-registered per request. Note the split: singleton() is what makes
Database shared across requests — warmUp() only caches reflection, so a
class that is merely warmed up is still built once per scope.
See scopes for what a scope shares with its parent and what it keeps to itself.
Binding closures receive the container:
$container->singleton(ReportBuilder::class, static function (ContainerInterface $c): ReportBuilder {
return new ReportBuilder(
$c->get(Database::class),
$c->get(TemplateEngine::class),
);
});use Gacela\Container\Attribute\Lazy;
#[Lazy]
final class ReportGenerator
{
public function __construct(private Database $db) {}
}Resolving it costs nothing; the constructor runs on first property access. See performance.
$service = $container->make(ReportService::class, ['format' => 'csv']);Overrides apply to the top-level constructor only, and the instance is always built fresh. A bound interface builds its bound class, and a key that names no constructor parameter throws. See runtime parameters.
Keep the shared wiring in one file and the per-environment differences in another. Later keys win, so layering is just load order:
$container = new Container();
$container->loadFile(__DIR__ . '/config/services.php');
$container->loadFile(__DIR__ . "/config/services.{$env}.php");// config/services.php
return [
LoggerInterface::class => FileLogger::class,
MailerInterface::class => ['singleton' => SmtpMailer::class],
'db.dsn' => ['value' => getenv('DATABASE_URL')],
];
// config/services.test.php
return [
MailerInterface::class => ['singleton' => NullMailer::class],
];A package can ship its defaults the same way and let the application override them. See definitions.
Compile constructor plans in a build step, load them at runtime:
// build step
$container = new Container($bindings);
$container->writeCompiledCache([UserService::class], __DIR__ . '/cache/container.php');
// runtime
$plans = Container::loadCompiledCache(__DIR__ . '/cache/container.php');
$container = new Container($bindings, [], $plans);Regenerate the file whenever constructors change. If a deploy forgets to, the entries whose class files moved on are dropped on load and those classes fall back to reflection — see staleness.
$container->getBindings(); // every abstract => concrete
$container->getDependencyTree(OrderService::class); // what it pulls in, flat
$container->getRegisteredServices(); // ids with a stored instance
$container->stats(); // counters, for debugging onlygetDependencyTree() is a flat, deduplicated list — it answers what is
touched, not how. When the question is why, print the graph:
echo $container->dependencyGraph(OrderService::class);
// App\OrderService
// ├── $repository: App\OrderRepository
// │ └── $db: App\Db
// └── $clock: App\SystemClockEach node carries the constructor parameter it satisfies, so you can see which
class asked for what and how deep it sits. Bindings are already resolved, so an
interface shows the concrete it maps to. A cycle is marked (cycle) and cut
rather than thrown — a broken graph is exactly when you reach for this:
$graph = $container->dependencyGraph(OrderService::class);
$graph->depth(); // 2
$graph->hasCycle(); // false
$graph->flatten(); // same list getDependencyTree() returnshas() and bound() answer different questions — see
resolving services.
$container->bound(MailerInterface::class); // was it explicitly registered?
$container->has(MailerInterface::class); // will get() resolve it?
bindIf() and singletonIf() register only when absent, which is how you
provide a default without stomping on a caller's choice:
$container->bindIf(MailerInterface::class, SmtpMailer::class);