Objects Namespaces And Application Architecture

Dependency Injection

Dependency injection means giving an object the collaborators it needs instead of letting it create or fetch them secretly.

In PHP applications, this usually means constructor injection: a service receives its repository, mailer, logger, clock, HTTP client, or formatter through __construct(). The result is code with visible dependencies that is easier to test and replace.

Inject A Collaborator Through The Constructor

PHP example
<?php

declare(strict_types=1);

final class MoneyFormatter
{
    public function formatPennies(int $pennies): string
    {
        return '£' . number_format($pennies / 100, 2);
    }
}

final class InvoicePresenter
{
    public function __construct(private MoneyFormatter $formatter)
    {
    }

    public function totalLabel(int $totalPennies): string
    {
        return 'Total: ' . $this->formatter->formatPennies($totalPennies);
    }
}

$presenter = new InvoicePresenter(new MoneyFormatter());

echo $presenter->totalLabel(2499) . PHP_EOL;

// Prints:
// Total: £24.99

The presenter does not create its formatter internally. The dependency is supplied from the outside.

Depend On Interfaces At Boundaries

Interfaces are useful when the dependency talks to the outside world or may have multiple implementations.

PHP example
<?php

declare(strict_types=1);

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

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

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

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

(new RegisterUser(new EchoMailer()))->handle('nia@example.com');

// Prints:
// Sending Welcome to nia@example.com

RegisterUser does not know whether the mailer uses SMTP, an API, a queue, or a fake implementation.

Injection Makes Tests Easier

A test can pass a recording fake instead of a real external service.

PHP example
<?php

declare(strict_types=1);

interface Clock
{
    public function now(): DateTimeImmutable;
}

final class FixedClock implements Clock
{
    public function now(): DateTimeImmutable
    {
        return new DateTimeImmutable('2026-05-20 09:00:00');
    }
}

final class TrialEndsAt
{
    public function __construct(private Clock $clock)
    {
    }

    public function afterDays(int $days): string
    {
        return $this->clock->now()->modify('+' . $days . ' days')->format('Y-m-d');
    }
}

echo (new TrialEndsAt(new FixedClock()))->afterDays(14) . PHP_EOL;

// Prints:
// 2026-06-03

The calculation is deterministic because the clock is injected.

Avoid Hidden Dependencies

Static calls, global variables, and service locators can hide what a class needs.

PHP example
<?php

declare(strict_types=1);

interface ReferenceGenerator
{
    public function orderReference(int $id): string;
}

final class SequentialReferenceGenerator implements ReferenceGenerator
{
    public function orderReference(int $id): string
    {
        return 'ORD-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT);
    }
}

final class OrderCreatedMessage
{
    public function __construct(private ReferenceGenerator $references)
    {
    }

    public function forOrder(int $id): string
    {
        return 'Created ' . $this->references->orderReference($id);
    }
}

echo (new OrderCreatedMessage(new SequentialReferenceGenerator()))->forOrder(42) . PHP_EOL;

// Prints:
// Created ORD-000042

The dependency is visible in the constructor, so a reviewer can see what the class needs.

Do Not Inject Plain Values Automatically

Inject services and collaborators. Plain values such as strings, IDs, and totals often belong in method arguments or value objects.

PHP example
<?php

declare(strict_types=1);

final class PasswordHasher
{
    public function hash(string $plainPassword): string
    {
        return password_hash($plainPassword, PASSWORD_DEFAULT);
    }
}

$hasher = new PasswordHasher();

echo password_verify('secret', $hasher->hash('secret')) ? 'verified' : 'failed';
echo PHP_EOL;

// Prints:
// verified

The hasher service is reusable. The password value is passed to the method at the time it is needed.

What To Remember

Dependency injection makes collaborators explicit. Prefer constructor injection for required services, depend on interfaces at external boundaries, use fakes in tests, and avoid hidden static or global dependencies that make code harder to reason about.

Construction Belongs At The Application Edge

Dependency injection separates two questions:

  1. What does this service do?
  2. Which concrete collaborators will this process use?

The service answers the first question. A composition root answers the second. In a small PHP application, the composition root may be an explicit bootstrap file. A framework may implement it through container configuration and autowiring. Either way, business code should receive ready-to-use collaborators rather than asking a global container to find them.

PHP example
<?php

declare(strict_types=1);

$pdo = new PDO($dsn, $username, $password);
$orders = new PdoOrderRepository($pdo);
$payments = new ProviderPaymentGateway($httpClient, $paymentConfig);
$clock = new SystemClock();

$checkout = new Checkout($orders, $payments, $clock);

This wiring is intentionally straightforward. The bootstrap knows infrastructure classes and configuration. Checkout knows only the contracts required to perform checkout.

Keep The Composition Root Boring

The composition root should contain construction decisions, not business decisions. It may choose a production mail adapter, create a PDO connection, and pass typed configuration into services. It should not calculate discounts, decide whether an order may ship, or catch domain exceptions merely because it is near the entry point.

Keeping wiring boring has practical benefits:

  • missing configuration fails during startup rather than halfway through a request;
  • the object graph can be reviewed without tracing global lookups;
  • HTTP, CLI, worker, and test entry points can reuse the same application services;
  • provider changes remain localized to adapter construction;
  • long-running process lifetimes can be configured deliberately.

When container configuration becomes difficult to read, inspect the object graph before adding aliases or factories. A constructor with twelve dependencies may reveal that one class owns too many responsibilities. Hiding that constructor behind autowiring does not improve the design.

Required, Optional, And Per-Operation Values

Constructor injection fits collaborators required for every valid instance. A repository, clock, logger, or payment gateway is normally required for the lifetime of a service.

Values that change per operation belong in method arguments or command objects:

PHP example
$checkout->complete($orderId, $paymentMethodId);

Do not inject an order ID into a long-lived checkout service. It is not a collaborator; it is input to one operation.

Optional dependencies deserve suspicion. A nullable mailer that changes whether a service sends email makes behavior depend on construction details callers cannot see. Prefer one of these alternatives:

  • require the collaborator;
  • inject a no-op implementation when doing nothing is a valid policy;
  • split optional behavior into a separate decorator or event handler;
  • expose two explicit use cases with different contracts.

The chosen design should make optional behavior visible rather than hiding it behind null.

Interfaces At Real Boundaries

An interface is valuable when the application needs a stable contract around volatile infrastructure or several meaningful implementations. Payment providers, clocks, queues, object storage, and external HTTP services are common examples.

Not every class needs an interface. Creating MoneyInterface for one immutable value object adds vocabulary without isolating a boundary. Begin with the concrete class when behavior is stable and local. Extract a contract when another implementation, test boundary, or dependency direction justifies it.

The application should own important boundary interfaces. If domain code depends directly on a provider SDK interface, provider concepts can spread through use cases and make replacement difficult. An application-owned PaymentGateway can express the exact operation and result the application needs; an adapter translates provider requests and exceptions.

Factories For Runtime Construction

Sometimes construction requires data known only during an operation. A factory can accept that runtime input while retaining injected collaborators:

PHP example
<?php

declare(strict_types=1);

final class InvoiceFactory
{
    public function __construct(private Clock $clock)
    {
    }

    public function create(int $orderId, int $totalPennies): Invoice
    {
        return new Invoice(
            orderId: $orderId,
            totalPennies: $totalPennies,
            issuedAt: $this->clock->now(),
        );
    }
}

The clock is a stable collaborator. The order ID and total are operation data. This separation keeps service lifetimes clear.

Service Lifetimes Matter

Containers often support shared, singleton, scoped, or transient services. The correct lifetime depends on state.

Stateless formatters and immutable configuration can usually be shared. Request-specific mutable objects should not be shared across requests. Database connections and HTTP clients may be shared within a process if their libraries support that lifecycle. Long-running workers need particular care because process-global or shared mutable state can leak between jobs.

Document non-default lifetimes and test several operations in one process. A unit test that creates a fresh container for every test will not reveal state retained by a production worker.

Avoid The Service Locator Pattern

This code hides dependencies:

PHP example
final class Checkout
{
    public function complete(int $orderId): void
    {
        $payments = Container::get(PaymentGateway::class);
        // ...
    }
}

The constructor appears dependency-free, but the method can fail because of unrelated global configuration. Tests must manipulate global state, and a reviewer cannot understand requirements from the class signature.

Passing the whole container through the constructor has the same problem in another form. Inject the specific collaborators instead.

Testing The Object Graph

Unit tests should test service behavior with focused fakes or stubs. Separate wiring tests should build the production object graph and verify that required services resolve. A smoke test should start the actual application with production-shaped configuration and perform one harmless operation through each important adapter.

Avoid mocks that assert every internal call in order. Those tests couple to implementation rather than business behavior. Prefer fakes that record a meaningful result, such as captured payment requests or saved orders, then assert the externally relevant outcome.

Test configuration failures too. Missing credentials, invalid DSNs, and unsupported provider modes should fail with actionable startup errors rather than null services or delayed surprises.

Review Checklist

  • Are all required collaborators visible in constructors?
  • Are per-operation values kept out of service construction?
  • Does each interface protect a real boundary?
  • Is business logic absent from the composition root?
  • Are service lifetimes safe for PHP-FPM and long-running workers?
  • Can provider exceptions be translated without leaking SDK types?
  • Does a wiring test construct the production graph?
  • Would removing autowiring reveal an oversized service?

Configuration Is Input, Not A Service Locator

Configuration values deserve explicit boundaries even though they are not service collaborators. Passing a generic array such as $config into every object hides which settings each service requires and postpones spelling or type errors until a rarely used path executes. A small immutable configuration object can validate related settings once at startup:

PHP example
<?php

declare(strict_types=1);

final readonly class MailerConfiguration
{
    public function __construct(
        public string $endpoint,
        public int $timeoutMilliseconds,
    ) {
        if (!filter_var($endpoint, FILTER_VALIDATE_URL)) {
            throw new InvalidArgumentException("Invalid mail endpoint");
        }

        if ($timeoutMilliseconds < 1) {
            throw new InvalidArgumentException("Timeout must be positive");
        }
    }
}

The composition root can read environment variables, convert them to precise types, construct this object, and inject it only into the adapter that needs it. Domain services should not read environment variables directly. Doing so makes process state a hidden dependency and allows configuration parsing rules to leak throughout the application.

Circular dependencies are another design warning. If OrderService needs InvoiceService while InvoiceService also needs OrderService, teaching the container to construct the cycle usually preserves the wrong boundary. Extract the shared policy, publish an application event, or move orchestration into a third service that depends on both. Lazy proxies may delay the failure, but they do not explain which component owns the workflow.

Decorators are a useful form of injection when several implementations need the same operational behavior. A logging repository can implement the repository interface, receive the real repository, record duration and outcomes, then delegate. Retry, tracing, metrics, and caching can use the same shape when their semantics fit the operation. Keep policy-specific behavior in the application rather than building a stack of invisible decorators whose order nobody can explain.

Framework container compilation or validation should run in CI using production-like configuration. This catches missing bindings, invalid scalar values, inaccessible classes, and accidental development-only services before deployment. Pair that graph check with a small boot test that resolves important entry points such as HTTP controllers, console commands, and queue handlers.

After this lesson, you should be able to place construction at the application edge, distinguish collaborators from operation data, choose interfaces for real boundaries, use factories for runtime construction, configure safe service lifetimes, and test both service behavior and production wiring.

Practice

Task: Inject A Mailer

Create a user registration service that receives its mailer dependency.

Requirements

  • Use declare(strict_types=1);.
  • Create a Mailer interface.
  • Create a RecordingMailer implementation.
  • Create a RegisterUser class that receives Mailer through the constructor.
  • Validate the email address in RegisterUser.
  • Send a welcome email through the injected mailer.
  • Print how many messages were recorded.
  • Show one invalid email by catching the exception.
  • Include the expected output as comments in the same PHP code block.

The registration service should not create the mailer internally.

Show solution
PHP example
<?php

declare(strict_types=1);

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

final class RecordingMailer implements Mailer
{
    public array $messages = [];

    public function send(string $to, string $subject): void
    {
        $this->messages[] = [$to, $subject];
    }
}

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

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

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

$mailer = new RecordingMailer();
$registerUser = new RegisterUser($mailer);
$registerUser->handle('NIA@example.com');

echo count($mailer->messages) . ' message recorded' . PHP_EOL;

try {
    $registerUser->handle('not-an-email');
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// 1 message recorded
// Email address is invalid.

RegisterUser receives its mailer from the outside. The example uses a recording implementation, but production code could inject an SMTP, API, or queue-backed mailer with the same interface.