Custom Processor 

A processor turns an ImageVariant into the URL that goes into srcset (and, for local processors, processes the file). All built-ins (local:async, local:sync, imgproxy and imagor) implement the same interface; your own is added the same way: put #[AsImaginatorProcessor('my-key')] on your class, pick the interface that fits, set processor = my-key — no YAML.

New CDN provider: a URL builder is enough 

For an external provider (CDN / image service) you rarely need a full processor — the non-grammar work (source-URL resolution, editor-crop replay, offloaded semantics) is already written once in ExternalImageProcessor. Implement only the provider's URL grammar:

Classes/UrlBuilder/MyCdnUrlBuilder.php (in your extension)
use Schliesser\Imaginator\Attribute\AsImaginatorProcessor;
use Schliesser\Imaginator\Dto\ExternalConfig;
use Schliesser\Imaginator\Dto\ImageVariant;
use Schliesser\Imaginator\Dto\Rectangle;
use Schliesser\Imaginator\UrlBuilder\UrlBuilderInterface;

#[AsImaginatorProcessor('my-cdn')]
final readonly class MyCdnUrlBuilder implements UrlBuilderInterface
{
    public function __construct(private ExternalConfig $config) {}

    public function build(ImageVariant $variant, string $sourceUrl, ?Rectangle $crop = null): string
    {
        return sprintf(
            '%s/%dx%d/%s',
            rtrim($this->config->baseUrl, '/'),
            $variant->width,
            $variant->height,
            $sourceUrl,
        );
    }
}
Copied!

The compiler pass wraps the builder in a configured ExternalImageProcessor automatically. Convention: the builder takes ExternalConfig as its sole constructor argument — it carries the unified processorBaseUrl / processorSignKey / processorSalt settings; ignore the fields your provider has no equivalent for (imagor ignores salt). Keep the builder pure (no I/O), so it stays golden-file testable.

Provider-specific extras (e.g. a Cloudflare account hash) arrive in ExternalConfig::$options; read them in the builder via $this->config->option('accountHash') or $this->config->requireOption('accountHash') — the latter throws a descriptive exception naming the exact configuration path, so a misconfiguration fails loudly instead of emitting broken URLs.

Where the options live depends on who ships the builder. Extension Configuration is namespaced per extension, so a builder shipped in your own extension declares its extension key on the attribute and keeps the options in its own namespace — its own ext_conf_template.txt, its own backend form:

#[AsImaginatorProcessor('my-cdn', extensionKey: 'my_cdn')]
final readonly class MyCdnUrlBuilder implements UrlBuilderInterface { /* … */ }
Copied!
config/system/settings.php (or your ext_conf_template.txt backend form)
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['my_cdn'] = [
    'accountHash' => 'abc123',
];
Copied!

Without extensionKey the options come from imaginator's own processorOptions map (used by the built-in providers; it has no backend UI):

$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['imaginator']['processorOptions'] = [
    'accountHash' => 'abc123',
];
Copied!

The full interface 

When URL grammar is not enough (local processing, exotic materialization), implement the processor interface itself — same attribute:

Classes/Imaging/ImageProcessorInterface.php
interface ImageProcessorInterface
{
    /** Render-time: URL that belongs in srcset for this variant. */
    public function buildUrl(ImageVariant $variant): string;

    /** True when pixels happen elsewhere (external CDN/service); local processing is skipped. */
    public function isOffloaded(): bool;

    /** Local processors only: produce/return the processed binary. */
    public function materialize(ImageVariant $variant): ProcessedImage;
}
Copied!
  • Return true from isOffloaded() for an external service (CDN / provider). buildUrl() then maps the variant onto the provider's URL grammar and materialize() is never called (the webserver never touches pixels).
  • Return false for a local processor; materialize() produces the derivative through TYPO3's ImageService.

Register it 

One attribute covers both shapes — the implemented interface decides:

#[AsImaginatorProcessor('my-cdn')]
final class MyProcessor implements ImageProcessorInterface { /* … */ }
Copied!
  • ImageProcessorInterface → the service itself is registered under the key.
  • UrlBuilderInterface → a configured ExternalImageProcessor wrapping the builder is registered under the key.
  • Neither or both → the container compile fails with a descriptive exception; duplicate and empty keys fail too (nothing silently last-wins).

Manual tagging remains a supported alternative — e.g. for multi-key aliasing or when your service needs its own factory. Tag it with imaginator.image_processor and a key; the registry is a lazy service locator indexed by that key:

Configuration/Services.yaml (in your extension)
Vendor\MyExt\Imaging\MyCdnProcessor:
  tags:
    - { name: 'imaginator.image_processor', key: 'my-cdn' }
Copied!

Escalation ladder: attribute on a URL builder (zero configuration code) → builder + options (extensionKey for your own namespace, or imaginator's processorOptions) → manual yaml named service with your own factory → full ImageProcessorInterface class (same attribute).

Select it with the processor setting:

config/system/settings.php
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['imaginator']['processor'] = 'my-cdn';
Copied!