Dependency Injection vs Service Locator

Testability in Modern PHP


Quick comparison

Attribute Dependency Injection Service Locator
How dependencies are provided Injected explicitly via constructor or setter Resolved from a global or passed locator object
Visibility Dependencies are explicit in the API Dependencies are hidden inside the locator
Testability Easy to mock and unit test Harder to isolate; often requires container setup
Ease of use More boilerplate for wiring Convenient for quick lookups and legacy code
Best for Large codebases, teams, libraries Small apps, scripts, or transitional code

Core concepts

Dependency Injection

Definition Dependencies are provided to an object from the outside, typically via constructor injection, setter injection, or method injection. The object does not know how to create its dependencies.

Service Locator

Definition A central registry or container exposes a method to retrieve services by name or interface. Objects ask the locator for the dependencies they need.


Minimal examples

Scenario

We have a Mailer service and an OrderProcessor that needs to send confirmation emails.

Dependency Injection example

<?php
interface MailerInterface {
    public function send(string $to, string $subject, string $body): bool;
}

class SmtpMailer implements MailerInterface {
    public function send(string $to, string $subject, string $body): bool {
        // send via SMTP
        return true;
    }
}

class OrderProcessor {
    private MailerInterface $mailer;

    public function __construct(MailerInterface $mailer) {
        $this->mailer = $mailer;
    }

    public function process(array $order): void {
        // process order...
        $this->mailer->send($order['email'], 'Order received', 'Thanks for your order');
    }
}

// wiring
$mailer = new SmtpMailer();
$processor = new OrderProcessor($mailer);
$processor->process(['email' => 'alice@example.com']);

Service Locator example

<?php
class ServiceLocator {
    private array $services = [];

    public function set(string $id, $service): void {
        $this->services[$id] = $service;
    }

    public function get(string $id) {
        return $this->services[$id] ?? null;
    }
}

class OrderProcessor {
    private ServiceLocator $locator;

    public function __construct(ServiceLocator $locator) {
        $this->locator = $locator;
    }

    public function process(array $order): void {
        $mailer = $this->locator->get('mailer');
        $mailer->send($order['email'], 'Order received', 'Thanks for your order');
    }
}

// wiring
$locator = new ServiceLocator();
$locator->set('mailer', new SmtpMailer());
$processor = new OrderProcessor($locator);
$processor->process(['email' => 'alice@example.com']);
Observation The DI version makes the dependency explicit in the constructor. The Service Locator hides the dependency behind a lookup call.

Unit testing comparison

Testing the DI version

With DI you can pass a test double directly into the constructor. No container setup required.

<?php
use PHPUnit\Framework\TestCase;

class OrderProcessorTest extends TestCase {
    public function testSendsEmail() {
        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->once())
              ->method('send')
              ->with('alice@example.com', 'Order received', $this->anything())
              ->willReturn(true);

        $processor = new OrderProcessor($mailer);
        $processor->process(['email' => 'alice@example.com']);
    }
}

Testing the Service Locator version

With a service locator you typically need to create a locator and register mocks, or mock the locator itself. This adds indirection and setup.

<?php
class OrderProcessorTest extends TestCase {
    public function testSendsEmail() {
        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->once())
              ->method('send');

        $locator = new ServiceLocator();
        $locator->set('mailer', $mailer);

        $processor = new OrderProcessor($locator);
        $processor->process(['email' => 'alice@example.com']);
    }
}
Why DI is easier for unit tests DI keeps tests focused on the unit under test. Service Locator tests often require container-like setup or mocking the locator, which couples tests to the locator API.

When Service Locator is reasonable


Common pitfalls and anti patterns


Migration checklist from Service Locator to Dependency Injection

  1. Identify hotspots Find classes that call the locator directly and are heavily used in business logic.
  2. Introduce interfaces Extract interfaces for services to decouple implementations from consumers.
  3. Refactor constructors Add constructor parameters for the dependencies used by the class.
  4. Update wiring Change the composition root to instantiate and inject dependencies. Keep the locator only in the composition root if needed.
  5. Write tests Add unit tests for the refactored classes using mocks for injected interfaces.
  6. Iterate Repeat for dependent classes, keeping changes small and covered by tests.
  7. Remove locator usage Once all consumers are refactored, remove the locator or restrict it to legacy adapters.

Practical tips for large PHP projects


Performance considerations

Both approaches have negligible runtime differences for most web apps. The main cost is wiring complexity and test runtime. If you use a DI container with autowiring, measure cold-start time for CLI or serverless environments. For high-performance hotspots, prefer explicit wiring and avoid runtime reflection in tight loops.


Example migration walkthrough

Refactor a controller that uses a locator into one that uses DI. Steps condensed for clarity.

<?php
// Before
class UserController {
    private ServiceLocator $locator;

    public function __construct(ServiceLocator $locator) {
        $this->locator = $locator;
    }

    public function register(array $data) {
        $userRepo = $this->locator->get('user_repository');
        $mailer = $this->locator->get('mailer');
        // ...
    }
}

// After
class UserController {
    private UserRepositoryInterface $userRepo;
    private MailerInterface $mailer;

    public function __construct(UserRepositoryInterface $userRepo, MailerInterface $mailer) {
        $this->userRepo = $userRepo;
        $this->mailer = $mailer;
    }

    public function register(array $data) {
        // dependencies are explicit and testable
    }
}

// Composition root
$container->set(UserRepositoryInterface::class, function() { return new UserRepository(); });
$container->set(MailerInterface::class, function() { return new SmtpMailer(); });
$controller = $container->get(UserController::class);

Checklist before you refactor