Custom Extractor Development
When to Use
The core extractors (svg, svg_sprite, path) and the contrib font extractor don't support your icon source (API, database, generated icons, external service).
Decision
| Icon source | Extractor approach | Complexity |
|---|---|---|
| REST API | HTTP client in discoverIcons() | Medium |
| Database table | Entity query in discoverIcons() | Low-Medium |
| Generated/computed | Logic in discoverIcons() | Medium |
| External service (Iconify, etc.) | API client with caching | Medium-High |
| Conditional icons | Runtime logic in loadIcon() | Low |
Two contracts from IconExtractorInterface that are easy to get wrong, and both fail hard:
discoverIcons(): arraymust return an array keyed by the fullpack_id:icon_id.IconCollector::getIconFromExtractor()looks up$definition['icons'][$icon_full_id]; a bareicon_idkey means the icon is listed in the admin UI and never renders. Build keys withIconDefinition::createIconId($this->configuration['id'], $icon_id).loadIcon(array $icon_data): ?IconDefinitionInterfacetakes the discovery array and returns anIconDefinition, not a string ID and an array. DeclaringloadIcon(string $icon_id): ?arrayis a fatal signature incompatibility at class load. If your discovery payload needs no extra work, inheritIconExtractorBase::loadIcon()and do not override it at all.
Extend IconExtractorBase for a source you resolve yourself; extend IconExtractorWithFinder if you want core's path/URL config: sources handling.
Pattern
Custom extractor plugin structure:
<?php
namespace Drupal\my_module\Plugin\IconExtractor;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Http\ClientFactory;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Theme\Icon\Attribute\IconExtractor;
use Drupal\Core\Theme\Icon\IconDefinition;
use Drupal\Core\Theme\Icon\IconExtractorBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
#[IconExtractor(
id: 'api_icons',
// label and description are TranslatableMarkup in every core extractor;
// IconExtractorBase::label() casts to string.
label: new TranslatableMarkup('API Icon Extractor'),
description: new TranslatableMarkup('Loads icons from external API.'),
)]
class ApiIconExtractor extends IconExtractorBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
protected ClientFactory $httpClientFactory,
protected CacheBackendInterface $cache
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(
ContainerInterface $container,
array $configuration,
$plugin_id,
$plugin_definition
) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('http_client_factory'),
$container->get('cache.default')
);
}
/**
* Discover all available icons from the API.
*/
public function discoverIcons(): array {
// There is no getPackId(); the pack ID arrives as configuration['id'].
$pack_id = $this->configuration['id'];
$cid = 'api_icons:' . $pack_id;
if ($cached = $this->cache->get($cid)) {
return $cached->data;
}
$icons = [];
// Extractor config lives under `config:` in the pack definition.
$api_url = $this->configuration['config']['api_url'] ?? '';
try {
$client = $this->httpClientFactory->fromOptions();
$response = $client->get($api_url);
$data = json_decode((string) $response->getBody(), TRUE);
foreach ($data['icons'] ?? [] as $icon) {
// Keys MUST be the full "pack_id:icon_id" or IconCollector never
// finds the icon and it renders as nothing.
$full_id = IconDefinition::createIconId($pack_id, $icon['id']);
$icons[$full_id] = [
// loadIcon() receives this array; icon_id is injected by
// IconCollector, source and any extra keys are yours.
'source' => $icon['svg_url'],
];
}
// Cache for 1 hour.
$this->cache->set($cid, $icons, time() + 3600, [
'icon_pack_plugin',
'api_icons',
]);
}
catch (\Exception $e) {
\Drupal::logger('my_module')->error(
'Failed to load icons from API: @error',
['@error' => $e->getMessage()]
);
}
return $icons;
}
// No loadIcon() override. IconExtractorBase::loadIcon(array $icon_data)
// already builds the IconDefinition from icon_id + source + group.
// Override it only to add extractor data, and keep the interface's
// signature: loadIcon(array $icon_data): ?IconDefinitionInterface.
}
Icon pack using custom extractor:
api_icons:
label: "API Icons"
extractor: api_icons
config:
api_url: "https://api.example.com/icons"
template: >-
<img src="{{ source }}"
width="{{ size|default(24) }}"
height="{{ size|default(24) }}"
alt="{{ alt|default('') }}">
Usage:
{{ icon('api_icons', 'home', { size: 32 }) }}
Reference: /core/lib/Drupal/Core/Theme/Icon/IconExtractorInterface.php
Common Mistakes
- Keying
discoverIcons()by bare icon ID → Icons appear in the admin listing and render as nothing - Overriding
loadIcon()with a different signature → Fatal at class load; the interface isloadIcon(array $icon_data): ?IconDefinitionInterface - Calling
$this->getPackId()→ No such method onIconExtractorBase; use$this->configuration['id'] - Reading config from
$this->configuration['my_key']→ Extractor config is nested underconfig: - Not implementing caching →
discoverIcons()runs on every cache rebuild, and for an HTTP source that is a slow, failure-prone rebuild - Missing error handling → An uncaught exception in
discoverIcons()propagates out ofprocessDefinition()and takes down the cache rebuild - Not using dependency injection → Inject services properly for testability
See Also
- Migration Patterns
- Reference:
/core/lib/Drupal/Core/Theme/Icon/IconExtractorBase.php,/core/lib/Drupal/Core/Theme/Icon/IconExtractorWithFinder.php - Reference:
/core/modules/system/tests/modules/icon_test/src/Plugin/IconExtractor/TestExtractor.phpfor a minimal working example in core - Reference: Plugin API