Build A Custom Drupal Service Provider For Module Changes
Drupal’s dependency injection container gives modules a controlled way to change service definitions before the application starts serving requests. A custom Drupal service provider for module alterations is useful when a module needs to adjust another service, add a method call, replace an implementation, or apply configuration during container compilation.
This approach is different from a procedural hook such as hook_form_alter() or hook_entity_type_alter(). Those hooks alter Drupal objects and build processes at runtime. A service provider works earlier, while Drupal is compiling the service container, so it is suitable for dependency injection and service-definition changes.
The examples below apply to modern Drupal versions, including Drupal 10 and Drupal 11. They use a small custom module, standard PHP namespaces, and service IDs that can be adapted to contributed modules or services defined elsewhere in a project.
Understand The Container Lifecycle
Drupal builds a service container from definitions supplied by core, modules, themes, and sites. A definition describes a service’s class, constructor arguments, method calls, tags, and other settings. Once Drupal compiles the container, the resulting PHP container is cached for later requests.
A module service provider participates in this compilation process. Drupal automatically looks for a class whose name follows the module’s machine name, such as ExampleModuleServiceProvider, in the module’s src directory. The class can change definitions before Drupal finishes compiling the container.
This timing matters. A service provider should work with ContainerBuilder and service definitions rather than requesting an already-built service. Calling $container->get() during compilation can produce circular dependencies, premature object creation, or an incorrect configuration state.
Service providers also have a different purpose from event subscribers. An event subscriber reacts to an event while Drupal is running; a service provider changes how the relevant service will be constructed. Use the provider for dependency wiring and the subscriber for request-time behaviour.
Create The Service Provider Class
Assume the custom module is called example_module. Create this file:
example_module/
example_module.info.yml
example_module.services.yml
src/
ExampleModuleServiceProvider.php
The class name uses Drupal’s module naming convention. For a module called customer_portal, the class would be CustomerPortalServiceProvider, stored at src/CustomerPortalServiceProvider.php.
<?php
namespace Drupal\example_module;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
use Symfony\Component\DependencyInjection\Reference;
final class ExampleModuleServiceProvider extends ServiceProviderBase {
/**
* Alters service definitions before the container is compiled.
*/
public function alter(ContainerBuilder $container): void {
if (!$container->has('catalogue_api.client')) {
return;
}
$definition = $container->getDefinition('catalogue_api.client');
$definition->addMethodCall('setAuditLogger', [
new Reference('logger.channel.example_module'),
]);
}
}
The has() check prevents a fatal error if the target service is optional or supplied by a module that is not enabled. The getDefinition() call returns the definition rather than the finished object. addMethodCall() then tells Drupal to call setAuditLogger() when it creates the service.
The target class must provide the method being called. It should also type its dependency clearly:
public function setAuditLogger(LoggerInterface $logger): void {
$this->auditLogger = $logger;
}
A service provider is not a place for business logic. Keep it focused on wiring, service replacement, compiler configuration, and other container-level changes. If the implementation requires a complex decision based on a request, account, or entity, place that logic in a normal injected service instead.
Register And Alter A Service
The target service needs to exist in the container. For a service owned by the custom module, example_module.services.yml might contain:
services:
logger.channel.example_module:
parent: logger.channel_base
arguments: ['example_module']
catalogue_api.client:
class: Drupal\example_module\CatalogueApiClient
arguments:
- '@http_client'
- '@config.factory'
The service ID is catalogue_api.client. Drupal passes http_client and config.factory into the constructor, then the service provider adds the audit logger method call during compilation. This keeps the original service definition readable while placing the alteration in the provider.
The same technique can alter a service defined by another module:
public function alter(ContainerBuilder $container): void {
if (!$container->has('third_party.processor')) {
return;
}
$definition = $container->getDefinition('third_party.processor');
$definition->addMethodCall('setCacheBackend', [
new Reference('cache.data'),
]);
}
Before using this pattern, inspect the service definition and confirm that the target class exposes the required method. Service IDs and constructor signatures can change between contributed module releases. Pinning a compatible version in Composer is sensible when an alteration depends on an internal implementation.
A provider can also replace a class, change an argument, or add a tag:
$definition->setClass(
\Drupal\example_module\ExtendedProcessor::class
);
$definition->addTag('example_module.processable', [
'priority' => 100,
]);
Replacing a class is more fragile than adding a method call. The replacement should preserve the expected public API and usually extend the original class or implement the same interface. Treat a service alteration as an integration point that needs release notes, tests, and review when the upstream module changes.
Use Decoration For Safer Overrides
Symfony service decoration is often the better option when the goal is to wrap existing behaviour. A decorator receives the original service as an injected inner service, adds its own behaviour, and delegates the remaining work. This avoids changing the original class directly.
For example, a decorator can record request timing around a client:
services:
example_module.catalogue_client_decorator:
class: Drupal\example_module\CatalogueClientDecorator
decorates: catalogue_api.client
arguments:
- '@example_module.catalogue_client_decorator.inner'
- '@logger.channel.example_module'
The decorator might implement the same interface as the original client:
final class CatalogueClientDecorator implements CatalogueClientInterface {
public function __construct(
private CatalogueClientInterface $inner,
private LoggerInterface $logger,
) {}
public function fetch(string $reference): array {
$started = microtime(TRUE);
try {
return $this->inner->fetch($reference);
}
finally {
$this->logger->info('Catalogue request completed in @time seconds.', [
'@time' => microtime(TRUE) - $started,
]);
}
}
}
Use the service provider when the alteration is a low-level container change, such as adding a compiler tag or method call. Use decoration when you need to intercept calls and preserve the original service as a collaborator. This distinction makes future maintenance easier for a team working across Brisbane, Perth, or a distributed Australian agency.
When an upstream module already documents a plugin, event, or decorator extension point, prefer that documented API. Altering a private service can solve an immediate problem, but it may create a maintenance burden during a Drupal core or contributed-module update.
Test Configuration And Runtime Behaviour
After adding or changing a service provider, rebuild Drupal’s caches. The compiled container is cached, so a new provider class or altered definition will not necessarily be recognised during the current request. With Drush, use:
drush cr
Then verify the definition and runtime result. The following command is useful when investigating service wiring:
drush php:eval '$container = \Drupal::getContainer(); var_dump(get_class($container->get("catalogue_api.client")));'
For a real project, automated tests are safer than relying on a manual request. A kernel test can boot Drupal, retrieve the service, and assert that the altered dependency or implementation is present. A functional test can then confirm the resulting behaviour through a route or form.
Test the missing-service branch as well. If the provider targets a service supplied by an optional module, the custom module should remain installable and cache-rebuildable without that dependency. Configuration imports should also work on a clean environment rather than only on a developer’s local database.
When debugging, inspect the compiled container and look for errors such as “You have requested a non-existent service”, “Cannot autowire service”, or a missing method. A short record of the expected service ID and upstream version can be valuable alongside a related technical note when a project contains unusual integration history.
Deploy With Australian Site Operations In Mind
Australian Drupal sites often serve users across several time zones and network conditions. A retail site aimed at Sydney and Melbourne may use Australian Eastern time, while teams in Perth coordinate three hours behind during daylight-saving months. Container changes are deployment changes, so rebuild caches as part of the release process rather than asking an editor to clear them manually in the arvo.
Many organisations also rely on managed hosting, platform pipelines, or NBN connections that behave differently between metropolitan offices and regional locations. Build the module with Composer in the deployment environment, run database updates and configuration imports in the documented order, then execute drush cr on every webhead or through the platform’s shared-cache mechanism.
For public-facing services, keep logs useful without exposing customer data. Australian retailers, councils, universities, and health organisations may have strict privacy and retention requirements. A decorator that logs every API payload could create unnecessary risk; logging a request identifier, response status, and elapsed time is usually enough for diagnosis.
Finally, check cacheability and accessibility after the service alteration reaches staging. A service that changes rendered output may require cache tags, contexts, or max-age adjustments, while a service used by a form may affect keyboard interaction or screen-reader text. Test from a staging environment that resembles the live site, including CDN behaviour and the Australian content delivery path, before releasing the provider to production.