Tina4

Chapter 25: DI Container#

1. The Problem with Global State#

Without dependency injection, your route handlers create their own service instances:

php
Router::post('/api/orders', function ($request, $response) {    $db    = new Database(getenv('TINA4_DATABASE_URL'));    $mailer = new Mailer(getenv('TINA4_MAIL_HOST'), getenv('TINA4_MAIL_PORT'));    $logger = new Logger('/var/log/app.log');    // ...});

Every handler creates fresh instances. Configuration is duplicated. Testing requires mocking at the class level. The code is tightly coupled to concrete implementations.

Dependency injection inverts this. You register services once. The container creates and manages them. Handlers receive what they need without knowing how it was constructed.

Tina4 provides a built-in Container class. No Reflection-based auto-wiring complexity. Register factories. Retrieve by name. Singletons for shared instances.


2. Creating a Container#

php
<?phpuse Tina4\Container;โ€‹$container = new Container();

The container is an empty registry. Register services into it. Retrieve them by name.


3. register() - Transient Services#

register() stores a factory callable. Each call to get() invokes the factory and returns a new instance.

php
<?phpuse Tina4\Container;โ€‹$container = new Container();โ€‹// Register a factory for a database connection$container->register('db', function () {    return new \Tina4\Database(getenv('TINA4_DATABASE_URL'));});โ€‹// Register a logger$container->register('logger', function () {    return new \Psr\Log\NullLogger();});โ€‹// Each get() creates a new instance$db1 = $container->get('db');$db2 = $container->get('db');โ€‹var_dump($db1 === $db2);   // bool(false) -- different instances

Use transient services when you need isolated instances per request or per operation.


4. singleton() - Shared Instances#

singleton() stores a factory that runs once. Every call to get() returns the same instance.

php
<?phpuse Tina4\Container;โ€‹$container = new Container();โ€‹// Register as singleton -- created once, reused everywhere$container->singleton('mailer', function () {    $mailer = new \Tina4\Messenger();    $mailer->setHost(getenv('TINA4_MAIL_HOST'));    $mailer->setPort((int) getenv('TINA4_MAIL_PORT'));    $mailer->setUsername(getenv('SMTP_USER'));    $mailer->setPassword(getenv('SMTP_PASS'));    return $mailer;});โ€‹$container->singleton('cache', function () {    return new \Tina4\Cache(backend: getenv('TINA4_CACHE_BACKEND', 'memory'));});โ€‹// Same instance every time$m1 = $container->get('mailer');$m2 = $container->get('mailer');โ€‹var_dump($m1 === $m2);   // bool(true) -- same instance

Use singletons for services that are expensive to create (database pools, HTTP clients, email configuration) or must share state (caches, event buses).


5. get() and has()#

get() retrieves a service by name. has() checks existence before retrieving.

php
<?phpuse Tina4\Container;โ€‹$container = new Container();$container->singleton('db', fn() => new \Tina4\Database(getenv('TINA4_DATABASE_URL')));โ€‹// Check before retrievingif ($container->has('db')) {    $db = $container->get('db');}โ€‹// Throws \Tina4\ContainerException if name not registeredtry {    $missing = $container->get('nonexistent');} catch (\Tina4\ContainerException $e) {    echo $e->getMessage();  // "Service 'nonexistent' is not registered"}

6. Services Depending on Other Services#

Factories receive the container instance as their argument. Use it to resolve dependencies:

php
<?phpuse Tina4\Container;โ€‹$container = new Container();โ€‹$container->singleton('logger', function () {    return new \Tina4\Debug();});โ€‹$container->singleton('db', function () {    return new \Tina4\Database(getenv('TINA4_DATABASE_URL'));});โ€‹// OrderService depends on db and logger$container->singleton('order_service', function () use ($container) {    return new OrderService(        db:     $container->get('db'),        logger: $container->get('logger')    );});โ€‹class OrderService {    public function __construct(        private \Tina4\Database $db,        private \Tina4\Debug $logger    ) {}โ€‹    public function createOrder(array $data): array {        $this->logger->message("Creating order", TINA4_LOG_INFO, $data);        // $this->db->execute(...);        return ['id' => rand(1000, 9999), ...$data];    }}โ€‹$orders = $container->get('order_service');$order  = $orders->createOrder(['customer' => 'Alice', 'total' => 59.99]);

Services are composed through the container. Each service is unaware of how its dependencies were built.


7. Binding a Container to Route Handlers#

Pass the container into your route handlers via use:

php
<?phpuse Tina4\Router;use Tina4\Container;โ€‹// Bootstrap container in app.php or index.php$container = new Container();โ€‹$container->singleton('db', fn() => new \Tina4\Database(getenv('TINA4_DATABASE_URL')));โ€‹$container->singleton('order_service', function () use ($container) {    return new OrderService($container->get('db'));});โ€‹// Route uses the containerRouter::post('/api/orders', function ($request, $response) use ($container) {    $orderService = $container->get('order_service');    $body = $request->body;โ€‹    $order = $orderService->createOrder([        'customer' => $body['customer'],        'items'    => $body['items'],        'total'    => $body['total']    ]);โ€‹    return $response->json(['order' => $order], 201);});โ€‹Router::get('/api/orders/{id:int}', function ($request, $response) use ($container) {    $orderService = $container->get('order_service');    $order = $orderService->findOrder($request->params['id']);โ€‹    if ($order === null) {        return $response->json(['error' => 'Order not found'], 404);    }โ€‹    return $response->json(['order' => $order]);});

8. reset() - Testing#

reset() clears all registrations. Use it between test cases to prevent state leakage:

php
<?phpuse Tina4\Container;โ€‹// In your test setUp$container = new Container();$container->singleton('db', fn() => new MockDatabase());โ€‹// Run the test$service = $container->get('db');assert($service instanceof MockDatabase);โ€‹// Tear down$container->reset();โ€‹// Container is empty againassert($container->has('db') === false);

9. A Full App Bootstrap#

src/bootstrap.php, all services registered in one place:

php
<?phpuse Tina4\Container;โ€‹$container = new Container();โ€‹// Infrastructure$container->singleton('db', function () {    return new \Tina4\Database(getenv('TINA4_DATABASE_URL'));});โ€‹$container->singleton('cache', function () {    return new \Tina4\Cache(getenv('TINA4_CACHE_BACKEND') ?: 'memory');});โ€‹$container->singleton('queue', function () {    return new \Tina4\Queue(topic: 'default');});โ€‹$container->singleton('mailer', function () {    $m = new \Tina4\Messenger();    $m->setHost(getenv('TINA4_MAIL_HOST'));    return $m;});โ€‹// Domain services$container->singleton('user_service', function () use ($container) {    return new UserService(        db:     $container->get('db'),        mailer: $container->get('mailer'),        cache:  $container->get('cache')    );});โ€‹$container->singleton('order_service', function () use ($container) {    return new OrderService(        db:    $container->get('db'),        queue: $container->get('queue')    );});โ€‹return $container;

In index.php:

php
$container = require 'src/bootstrap.php';โ€‹// Pass to all routesrequire 'src/routes/users.php';require 'src/routes/orders.php';

10. Gotchas#

1. Circular dependencies#

Problem: Service A depends on B, and B depends on A. The container loops forever.

Cause: A circular dependency in your service graph.

Fix: Introduce a third service that both A and B depend on, or restructure to break the cycle. The container does not detect cycles automatically, you will see a PHP stack overflow.

2. Forgetting to use singleton for stateful services#

Problem: Two calls to $container->get('mailer') produce two SMTP connections.

Cause: Registered with register() instead of singleton().

Fix: Use singleton() for any service where a single shared instance is correct. Use register() only when you explicitly need a new instance each time.

3. Not resetting between tests#

Problem: A singleton registered in test A bleeds into test B.

Cause: Tests share the same container instance without calling reset().

Fix: Create a fresh new Container() or call $container->reset() in setUp() in each test class.

4. Storing request-scoped data in a singleton#

Problem: User A's request data appears in User B's response.

Cause: A singleton service stores per-request state (current user, request context) on the instance itself.

Fix: Singletons must be stateless or explicitly scoped. Pass per-request data as method arguments rather than storing it on the service instance.