03

Dependency Injection for Custom Classes Using ContainerInjectionInterface

Introduction

You understand Drupal services and dependency injection. But what about classes that should not be services--data transfer objects, value objects, and helper classes that represent specific instances rather than reusable application logic?

Many developers solve this incorrectly by creating unnecessary services or using static \Drupal::service() calls. This tutorial shows the correct approach: implementing ContainerInjectionInterface.

What you will learn:

  • When to use ContainerInjectionInterface versus creating a service
  • How to implement the interface correctly
  • How to instantiate classes using \Drupal::classResolver()

Prerequisites:

  • Understanding of Drupal services and dependency injection
  • Familiarity with PHP 8+ syntax including constructor property promotion
  • Experience with custom Drupal module development

Estimated completion time: 15-20 minutes


The Problem: When Services Are Not the Answer

Consider a class representing a location that needs access to a mapping service:

The wrong approach (static service locator):

<?php

declare(strict_types=1);

namespace Drupal\my_module\Entity;

class LocationEntity {

  public function __construct(string $address) {
    // BAD: Tightly coupled to the global container
    $mapService = \Drupal::service('my_module.map_service');
    $coordinates = $mapService->geocode($address);
  }

}

Also wrong (making it a service):

# DON'T DO THIS - services should be stateless singletons
services:
  my_module.location_entity:
    class: Drupal\my_module\Entity\LocationEntity

Services are for stateless, reusable application logic. A LocationEntity represents specific data--you might need many instances with different addresses.


How ContainerInjectionInterface Works

The interface requires one static method:

public static function create(ContainerInterface $container);

When Drupal's class resolver instantiates your class, it:

  1. Calls the static create() method with the service container
  2. Your create() method pulls needed services and returns a new instance

Instantiation:

$instance = \Drupal::classResolver(MyClass::class);

Step-by-Step Implementation

Step 1: Create the Dependency Service

Create web/modules/custom/my_module/src/Service/MapService.php:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Service;

use Drupal\Core\Cache\CacheBackendInterface;
use Psr\Log\LoggerInterface;

/**
 * Service for geocoding and mapping operations.
 */
class MapService {

  public function __construct(
    protected CacheBackendInterface $cache,
    protected LoggerInterface $logger,
  ) {}

  /**
   * Geocodes an address to coordinates.
   *
   * @return array{lat: float, lng: float}
   */
  public function geocode(string $address): array {
    $cid = 'geocode:' . md5($address);
    $cached = $this->cache->get($cid);

    if ($cached) {
      return $cached->data;
    }

    // In production, call an external API here.
    $coordinates = [
      'lat' => 40.7128 + (crc32($address) % 100) / 1000,
      'lng' => -74.0060 + (crc32($address) % 100) / 1000,
    ];

    $this->cache->set($cid, $coordinates, CacheBackendInterface::CACHE_PERMANENT);
    return $coordinates;
  }

  /**
   * Calculates distance between two points in kilometers.
   */
  public function calculateDistance(
    float $lat1,
    float $lng1,
    float $lat2,
    float $lng2,
  ): float {
    $earthRadius = 6371;
    $latDelta = deg2rad($lat2 - $lat1);
    $lngDelta = deg2rad($lng2 - $lng1);

    $a = sin($latDelta / 2) * sin($latDelta / 2)
      + cos(deg2rad($lat1)) * cos(deg2rad($lat2))
      * sin($lngDelta / 2) * sin($lngDelta / 2);

    return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
  }

}

Register it in web/modules/custom/my_module/my_module.services.yml:

services:
  my_module.map_service:
    class: Drupal\my_module\Service\MapService
    arguments:
      - '@cache.default'
      - '@logger.channel.my_module'

  logger.channel.my_module:
    parent: logger.channel_base
    arguments: ['my_module']

Step 2: Create the Class with ContainerInjectionInterface

Create web/modules/custom/my_module/src/Entity/LocationEntity.php:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Entity;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\my_module\Service\MapService;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Represents a geographic location with an address and coordinates.
 */
class LocationEntity implements ContainerInjectionInterface {

  protected string $address = '';
  protected float $latitude = 0.0;
  protected float $longitude = 0.0;

  public function __construct(
    protected MapService $mapService,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('my_module.map_service'),
    );
  }

  /**
   * Sets the location address and geocodes it.
   */
  public function setAddress(string $address): static {
    $this->address = $address;
    $coordinates = $this->mapService->geocode($address);
    $this->latitude = $coordinates['lat'];
    $this->longitude = $coordinates['lng'];
    return $this;
  }

  public function getAddress(): string {
    return $this->address;
  }

  public function getLatitude(): float {
    return $this->latitude;
  }

  public function getLongitude(): float {
    return $this->longitude;
  }

  /**
   * Calculates distance to another location in kilometers.
   */
  public function distanceTo(LocationEntity $other): float {
    return $this->mapService->calculateDistance(
      $this->latitude,
      $this->longitude,
      $other->getLatitude(),
      $other->getLongitude(),
    );
  }

  /**
   * @return array{address: string, lat: float, lng: float}
   */
  public function toArray(): array {
    return [
      'address' => $this->address,
      'lat' => $this->latitude,
      'lng' => $this->longitude,
    ];
  }

}

Key points:

  • Use new static() instead of new self() for inheritance compatibility
  • Constructor receives dependencies; setters handle instance data
  • Properties for data are separate from injected dependencies

Step 3: Use the Class

// Create instances with proper dependency injection
$location = \Drupal::classResolver(LocationEntity::class);
$location->setAddress('123 Main Street, New York, NY 10001');

$otherLocation = \Drupal::classResolver(LocationEntity::class);
$otherLocation->setAddress('456 Broadway, New York, NY 10012');

$distance = $location->distanceTo($otherLocation);

Using in Controllers and Services

For better testability, inject the class_resolver service instead of using the static method:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\my_module\Entity\LocationEntity;
use Symfony\Component\DependencyInjection\ContainerInterface;

class LocationController extends ControllerBase {

  public function __construct(
    protected ClassResolverInterface $classResolver,
  ) {}

  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('class_resolver'),
    );
  }

  public function calculateDistance(string $from, string $to): array {
    /** @var \Drupal\my_module\Entity\LocationEntity $origin */
    $origin = $this->classResolver->getInstanceFromDefinition(LocationEntity::class);
    $origin->setAddress($from);

    /** @var \Drupal\my_module\Entity\LocationEntity $destination */
    $destination = $this->classResolver->getInstanceFromDefinition(LocationEntity::class);
    $destination->setAddress($to);

    return [
      '#markup' => $this->t('Distance: @km km', ['@km' => round($origin->distanceTo($destination), 2)]),
    ];
  }

}

Passing Initial Data

Since create() only receives the container, use one of these patterns:

Setter Methods (Fluent Interface)

$location = \Drupal::classResolver(LocationEntity::class);
$location->setAddress('123 Main St');

Static Factory Methods

public static function fromAddress(string $address): static {
  $instance = \Drupal::classResolver(static::class);
  $instance->setAddress($address);
  return $instance;
}

public static function fromCoordinates(float $lat, float $lng): static {
  $instance = \Drupal::classResolver(static::class);
  $instance->latitude = $lat;
  $instance->longitude = $lng;
  return $instance;
}

// Usage:
$location = LocationEntity::fromAddress('123 Main St');

Factory Service

For complex instantiation, create a dedicated factory:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Factory;

use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\my_module\Entity\LocationEntity;

class LocationEntityFactory {

  public function __construct(
    protected ClassResolverInterface $classResolver,
  ) {}

  public function createFromAddress(string $address): LocationEntity {
    /** @var \Drupal\my_module\Entity\LocationEntity $location */
    $location = $this->classResolver->getInstanceFromDefinition(LocationEntity::class);
    $location->setAddress($address);
    return $location;
  }

}
services:
  my_module.location_factory:
    class: Drupal\my_module\Factory\LocationEntityFactory
    arguments:
      - '@class_resolver'

Common Use Cases

Data Transfer Object (DTO)

<?php

declare(strict_types=1);

namespace Drupal\my_module\DTO;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class UserProfileDTO implements ContainerInjectionInterface {

  protected int $userId = 0;
  protected string $displayName = '';
  protected array $roles = [];

  public function __construct(
    protected EntityTypeManagerInterface $entityTypeManager,
  ) {}

  public static function create(ContainerInterface $container): static {
    return new static($container->get('entity_type.manager'));
  }

  public function loadFromUserId(int $userId): static {
    $user = $this->entityTypeManager->getStorage('user')->load($userId);
    if ($user) {
      $this->userId = (int) $user->id();
      $this->displayName = $user->getDisplayName();
      $this->roles = $user->getRoles();
    }
    return $this;
  }

}

Value Object with Validation

<?php

declare(strict_types=1);

namespace Drupal\my_module\ValueObject;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class EmailAddress implements ContainerInjectionInterface {

  protected string $email = '';
  protected bool $isValid = FALSE;

  public function __construct(
    protected ConfigFactoryInterface $configFactory,
  ) {}

  public static function create(ContainerInterface $container): static {
    return new static($container->get('config.factory'));
  }

  public function setValue(string $email): static {
    $this->email = $email;
    $this->isValid = $this->validate();
    return $this;
  }

  protected function validate(): bool {
    if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
      return FALSE;
    }

    $blockedDomains = $this->configFactory
      ->get('my_module.settings')
      ->get('blocked_email_domains') ?? [];

    $domain = substr(strrchr($this->email, '@'), 1);
    return !in_array($domain, $blockedDomains, TRUE);
  }

  public function isValid(): bool {
    return $this->isValid;
  }

}

Best Practices

When to Use Which Approach

ContainerInjectionInterface Service
Multiple instances needed Single shared instance
Holds instance-specific data Stateless or minimal state
DTOs, value objects, entities Business logic utilities
Short-lived objects Long-lived application utilities

Implementation Checklist

  • Always use declare(strict_types=1);
  • Use new static() in create() for inheritance
  • Type-hint dependencies to interfaces when available
  • Separate instance data from injected dependencies
  • Inject class_resolver in classes rather than using \Drupal::classResolver() statically

Common Mistakes to Avoid

  • Creating services for classes needing multiple instances
  • Using \Drupal::service() inside classes
  • Mixing constructor data and dependencies--use setters for instance data
  • Forgetting to clear cache after changes

Testing

Classes using this pattern are easily testable:

<?php

declare(strict_types=1);

namespace Drupal\Tests\my_module\Unit;

use Drupal\my_module\Entity\LocationEntity;
use Drupal\my_module\Service\MapService;
use Drupal\Tests\UnitTestCase;

/**
 * @coversDefaultClass \Drupal\my_module\Entity\LocationEntity
 * @group my_module
 */
class LocationEntityTest extends UnitTestCase {

  public function testSetAddress(): void {
    $mapService = $this->createMock(MapService::class);
    $mapService->expects($this->once())
      ->method('geocode')
      ->with('123 Main St')
      ->willReturn(['lat' => 40.7128, 'lng' => -74.0060]);

    $location = new LocationEntity($mapService);
    $location->setAddress('123 Main St');

    $this->assertEquals(40.7128, $location->getLatitude());
    $this->assertEquals(-74.0060, $location->getLongitude());
  }

}

Quick Reference

Minimal Implementation

<?php

declare(strict_types=1);

namespace Drupal\my_module\Entity;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\my_module\Service\MyService;
use Symfony\Component\DependencyInjection\ContainerInterface;

class MyEntity implements ContainerInjectionInterface {

  public function __construct(
    protected MyService $myService,
  ) {}

  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('my_module.my_service'),
    );
  }

}

Instantiation

// Static (in hooks or procedural code):
$instance = \Drupal::classResolver(MyEntity::class);

// Injected (preferred in classes):
$instance = $this->classResolver->getInstanceFromDefinition(MyEntity::class);

Further Reading

Tags