From drupal-workflow
Provides Drupal 10/11 service definitions, constructor injection, interfaces, and plugin factories. Use when creating services, registering dependencies, or service container patterns.
npx claudepluginhub gkastanis/drupal-workflow --plugin drupal-workflowThis skill uses the workspace's default tool permissions.
Always use constructor injection — never use `\Drupal::` static calls in classes. Always check the structural index before creating new services to avoid duplicates. Prefer interface type-hints over concrete classes.
Enforces core Drupal 10+ rules for services, dependency injection, security including sanitization and access control, code quality, and testing verification. Always use when writing Drupal code.
Searches Drupal core modules for patterns like forms, entities, services, plugins, controllers; returns file paths, quick references, and grep/glob strategies.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
Always use constructor injection — never use \Drupal:: static calls in classes. Always check the structural index before creating new services to avoid duplicates. Prefer interface type-hints over concrete classes.
# my_module.services.yml
services:
my_module.content_manager:
class: Drupal\my_module\ContentManager
arguments:
- '@entity_type.manager'
- '@current_user'
- '@logger.channel.my_module'
my_module.event_subscriber:
class: Drupal\my_module\EventSubscriber\MyEventSubscriber
arguments:
- '@my_module.content_manager'
tags:
- { name: event_subscriber }
declare(strict_types=1);
namespace Drupal\my_module;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Psr\Log\LoggerInterface;
final class ContentManager implements ContentManagerInterface {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
private readonly AccountProxyInterface $currentUser,
private readonly LoggerInterface $logger,
) {}
}
declare(strict_types=1);
namespace Drupal\my_module;
interface ContentManagerInterface {
/**
* Loads content items for the current user.
*
* @return \Drupal\node\NodeInterface[]
* Array of node entities.
*/
public function loadUserContent(): array;
}
declare(strict_types=1);
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\my_module\ContentManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a content list block.
*
* @Block(
* id = "my_module_content_list",
* admin_label = @Translation("Content List"),
* )
*/
final class ContentListBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly ContentManagerInterface $contentManager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(
ContainerInterface $container,
array $configuration,
$plugin_id,
$plugin_definition,
): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('my_module.content_manager'),
);
}
public function build(): array {
return $this->contentManager->loadUserContent();
}
}
// Bad: static service call.
$node = \Drupal::entityTypeManager()->getStorage('node')->load($nid);
// Good: injected dependency.
$node = $this->entityTypeManager->getStorage('node')->load($nid);
// Bad: no interface type-hint.
class MyService {
public function __construct(private readonly ContentManager $manager) {}
}
// Good: interface type-hint.
class MyService {
public function __construct(private readonly ContentManagerInterface $manager) {}
}
Service names follow no universal convention. Don't guess -- verify.
# Quick check: does a service exist?
ddev drush eval 'print json_encode(["exists" => Drupal::hasService("module_name.service_name")]);'
When a service name fails: Read the module's *.services.yml directly rather than guessing variations. The module prefix may be singular (group_permission.checker) when you expect plural (group_permissions.checker).
When a module exists in both vendor/drupal/ and web/modules/contrib/, only one is actually loaded by PHP.
// Find which file is running at runtime
$ref = new \ReflectionMethod($service, 'methodName');
echo $ref->getFileName();
// Edit the file that ReflectionMethod reports, not the one you assume.
Common scenario: A composer-patched module. The original sits in vendor/, the patched version in web/modules/contrib/. Editing the vendor copy has no effect because the autoloader loads from contrib.
| Service ID | Interface | Purpose |
|---|---|---|
entity_type.manager | EntityTypeManagerInterface | Entity CRUD |
current_user | AccountProxyInterface | Current user |
config.factory | ConfigFactoryInterface | Configuration |
logger.factory | LoggerChannelFactoryInterface | Logging |
messenger | MessengerInterface | User messages |
module_handler | ModuleHandlerInterface | Module operations |
state | StateInterface | Key-value state |
cache.default | CacheBackendInterface | Default cache |