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
ContainerInjectionInterfaceversus 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:
- Calls the static
create()method with the service container - 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 ofnew 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()increate()for inheritance - Type-hint dependencies to interfaces when available
- Separate instance data from injected dependencies
- Inject
class_resolverin 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);