objects namespaces and application architecture

Service Container Orientation

A service container is an object that builds and stores application services for you. Modern PHP frameworks use containers so controllers, commands, event listeners, and jobs can receive their dependencies without manually wiring every object in every file.

You have already seen constructor injection: a class asks for what it needs in its constructor. A service container is the tool that supplies those constructor arguments at runtime.

Without a container, object wiring can become noisy:

PHP example
<?php

declare(strict_types=1);

interface Mailer
{
    public function send(string $to, string $message): void;
}

final class LogMailer implements Mailer
{
    public function send(string $to, string $message): void
    {
        echo $to . ': ' . $message . PHP_EOL;
    }
}

final class RegisterUser
{
    public function __construct(
        private Mailer $mailer,
    ) {
    }

    public function handle(string $email): void
    {
        $this->mailer->send($email, 'Welcome.');
    }
}

$mailer = new LogMailer();
$registerUser = new RegisterUser($mailer);

$registerUser->handle('ada@example.com');

// Prints:
// ada@example.com: Welcome.

This example is simple, but a real controller might depend on an application service, which depends on a repository, hasher, mailer, clock, logger, and configuration value. A container keeps that wiring in one predictable place.

Services

A service is usually an object that performs work for the application. Repositories, mailers, password hashers, HTTP clients, loggers, payment gateways, and application services are all common services.

Simple data objects are usually not container services. You normally create objects such as EmailAddress, Money, OrderLine, and RegistrationResult directly because they represent values for a specific operation.

PHP example
<?php

declare(strict_types=1);

final readonly class EmailAddress
{
    public function __construct(
        public string $value,
    ) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Email address is not valid.');
        }
    }
}

$email = new EmailAddress('grace@example.com');

echo $email->value . PHP_EOL;

// Prints:
// grace@example.com

The container should not be used just because new exists. Use it for shared application services and dependency graphs, not for every object.

Binding Interfaces To Implementations

Containers become important when a class depends on an interface.

PHP cannot guess which concrete class should be used for an interface. The application must bind the interface to an implementation.

PHP example
<?php

declare(strict_types=1);

interface Mailer
{
    public function send(string $to, string $message): void;
}

final class SmtpMailer implements Mailer
{
    public function send(string $to, string $message): void
    {
        echo 'SMTP to ' . $to . ': ' . $message . PHP_EOL;
    }
}

final class RegisterUser
{
    public function __construct(
        private Mailer $mailer,
    ) {
    }

    public function handle(string $email): void
    {
        $this->mailer->send($email, 'Welcome.');
    }
}

final class Container
{
    /** @var array<string, callable(self): object> */
    private array $bindings = [];

    public function bind(string $id, callable $factory): void
    {
        $this->bindings[$id] = $factory;
    }

    public function get(string $id): object
    {
        if (!isset($this->bindings[$id])) {
            throw new RuntimeException('Nothing is bound for ' . $id);
        }

        return $this->bindings[$id]($this);
    }
}

$container = new Container();

$container->bind(Mailer::class, fn (): Mailer => new SmtpMailer());
$container->bind(RegisterUser::class, fn (Container $container): RegisterUser => new RegisterUser(
    $container->get(Mailer::class),
));

$registerUser = $container->get(RegisterUser::class);
$registerUser->handle('ada@example.com');

// Prints:
// SMTP to ada@example.com: Welcome.

Laravel, Symfony, Laminas, Spiral, and other frameworks provide much more powerful containers than this example. The idea is the same: the container knows how to build services.

Autowiring

Autowiring means the container reads constructor type declarations and automatically creates dependencies when it can.

If a constructor asks for a concrete class, many containers can build it without extra configuration:

PHP example
<?php

declare(strict_types=1);

final class AuditLogger
{
    public function record(string $message): void
    {
        echo $message . PHP_EOL;
    }
}

final class DeleteAccount
{
    public function __construct(
        private AuditLogger $logger,
    ) {
    }

    public function handle(int $userId): void
    {
        $this->logger->record('Deleted user ' . $userId);
    }
}

A framework container can often see that DeleteAccount needs AuditLogger, then create both. But autowiring cannot decide everything. Interfaces, scalar values, environment-specific services, and third-party clients usually need explicit configuration.

Shared Services And New Instances

Some services should usually be shared. A logger, database connection, cache client, or configured HTTP client may be reused throughout a request.

Other objects should be new each time. A command object containing request data, a value object, or a result object should not be stored as a shared service.

PHP example
<?php

declare(strict_types=1);

final class Counter
{
    public int $value = 0;
}

$shared = new Counter();
$shared->value++;

$sameSharedObject = $shared;
$sameSharedObject->value++;

echo $shared->value . PHP_EOL;

// Prints:
// 2

Shared mutable services can surprise developers if they hold request-specific state. As a rule, services registered in the container should be stateless or carefully managed.

Avoid Service Locator Style

A service locator is when application classes pull dependencies out of the container themselves.

PHP example
<?php

declare(strict_types=1);

final class BadRegisterUser
{
    public function __construct(
        private Container $container,
    ) {
    }

    public function handle(string $email): void
    {
        $mailer = $this->container->get(Mailer::class);

        if (!$mailer instanceof Mailer) {
            throw new RuntimeException('Mailer binding is invalid.');
        }

        $mailer->send($email, 'Welcome.');
    }
}

This hides the real dependency. The constructor says the class needs a container, but the method actually needs a mailer. That makes the class harder to understand, harder to test, and easier to break at runtime.

Prefer this:

PHP example
<?php

declare(strict_types=1);

final class RegisterUser
{
    public function __construct(
        private Mailer $mailer,
    ) {
    }

    public function handle(string $email): void
    {
        $this->mailer->send($email, 'Welcome.');
    }
}

Let the framework use the container at the edge of the application. Inside your own classes, ask for dependencies directly.

Where You See Containers In PHP Jobs

In Laravel, you may see service providers, interface bindings, singleton registrations, contextual bindings, facades, and constructor injection into controllers or jobs.

In Symfony, you may see service definitions, autowiring, autoconfiguration, aliases, tagged services, and compiler passes.

In many smaller applications, you may see PHP-DI, League Container, Laminas ServiceManager, or a hand-written container.

The framework details differ, but the review questions are the same:

  • Can another developer see the class dependencies from the constructor?
  • Are interfaces bound to the correct implementations?
  • Is environment-specific setup kept near application bootstrapping?
  • Are services stateless unless there is a clear reason?
  • Is the container kept out of domain and application logic?

What You Should Be Able To Do

After this lesson, you should understand that a service container builds services and supplies dependencies. You should be able to explain the difference between constructor injection and fetching from the container, recognise when an interface needs a binding, and avoid using the container as a hidden global dependency.

For junior work, this matters because many framework errors are container errors: a class cannot be resolved, an interface is not bound, a scalar config value is missing, or the wrong implementation has been registered. Understanding the container makes those errors much less mysterious.

Practice

Practice: Wire A Small Service Container

Create a tiny service container example that wires an application service to an interface implementation.

Task

Build:

  • a Notifier interface
  • a concrete notifier implementation that prints messages
  • an application service that receives Notifier through its constructor
  • a small container with bind() and get() methods
  • bindings for the interface and the application service

The application service should not receive the container. It should receive the dependency it actually needs.

Check Your Work

Run the example and confirm:

  • the application service can send a message through the notifier
  • requesting an unknown service throws a clear exception
  • the application service constructor names Notifier, not Container

Afterward, explain why this is constructor injection supported by a container, not service locator style.

Show solution

This solution keeps the container at the wiring edge. SendWelcomeMessage receives a Notifier, not the container itself.

PHP example
<?php

declare(strict_types=1);

interface Notifier
{
    public function send(string $recipient, string $message): void;
}

final class ConsoleNotifier implements Notifier
{
    public function send(string $recipient, string $message): void
    {
        echo $recipient . ': ' . $message . PHP_EOL;
    }
}

final class SendWelcomeMessage
{
    public function __construct(
        private Notifier $notifier,
    ) {
    }

    public function handle(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Email address is not valid.');
        }

        $this->notifier->send($email, 'Welcome to the application.');
    }
}

final class Container
{
    /** @var array<string, callable(self): object> */
    private array $bindings = [];

    public function bind(string $id, callable $factory): void
    {
        $this->bindings[$id] = $factory;
    }

    public function get(string $id): object
    {
        if (!isset($this->bindings[$id])) {
            throw new RuntimeException('No service has been bound for ' . $id);
        }

        return $this->bindings[$id]($this);
    }
}

$container = new Container();

$container->bind(Notifier::class, fn (): Notifier => new ConsoleNotifier());
$container->bind(SendWelcomeMessage::class, fn (Container $container): SendWelcomeMessage => new SendWelcomeMessage(
    $container->get(Notifier::class),
));

$service = $container->get(SendWelcomeMessage::class);

if (!$service instanceof SendWelcomeMessage) {
    throw new RuntimeException('Container returned the wrong service.');
}

$service->handle('ada@example.com');

try {
    $container->get('missing-service');
} catch (RuntimeException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// ada@example.com: Welcome to the application.
// No service has been bound for missing-service

Container knows how to build objects, but SendWelcomeMessage does not know the container exists. That is the important boundary. The application service declares its real dependency, and the wiring code decides which implementation should satisfy it.