Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
"Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses",
"Announcements": "CraftCms\\Cms\\Support\\Facades\\Announcements",
"AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer",
"AssetTransforms": "CraftCms\\Cms\\Support\\Facades\\AssetTransforms",
"Assets": "CraftCms\\Cms\\Support\\Facades\\Assets",
"AuthMethods": "CraftCms\\Cms\\Support\\Facades\\AuthMethods",
"BulkOps": "CraftCms\\Cms\\Support\\Facades\\BulkOps",
Expand Down
242 changes: 242 additions & 0 deletions docs/asset-transformers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
# Asset Transformers

Craft 6 includes an asset transformer registry that lets plugins add new ways to transform assets.

Craft registers its built-in image transformer with the `craft` handle. Plugins can register additional transformer handles for remote image services, video processors, PDF preview generators, text renderers, or any other asset transformation workflow.

The registry is available through `CraftCms\Cms\Asset\AssetTransforms` or the `CraftCms\Cms\Support\Facades\AssetTransforms` facade.

## How Transformers Are Selected

When an asset transform is requested, Craft resolves the transformer in this order:

1. the `transformer` value in the transform array
2. the transformer selected by the named image transform
3. the asset filesystem's default transformer
4. Craft's built-in `craft` transformer

If a configured transformer handle is missing, Craft logs a warning and falls back to `craft`.

```php
<?php

$url = $asset->getUrl([
'transformer' => 'my-service',
'width' => 1200,
'height' => 800,
'blur' => 12,
]);
```

Unknown transform options are preserved on the transform's `settings` array. Transformers can use the options they support and ignore the rest.

## Registering a Transformer

Register transformers by listening for `AssetTransformersResolving`.

```php
<?php

namespace App\Providers;

use App\Assets\MyServiceTransformer;
use CraftCms\Cms\Image\Events\AssetTransformersResolving;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(AssetTransformersResolving::class, function (AssetTransformersResolving $event) {
$event->types[MyServiceTransformer::handle()] = MyServiceTransformer::class;
});
}
}
```

Handles should be stable, unique, and safe to store in project config.

## Implementing a Transformer

Transformers implement `CraftCms\Cms\Image\Contracts\AssetTransformerInterface`.

```php
<?php

namespace App\Assets;

use CraftCms\Cms\Asset\Elements\Asset;
use CraftCms\Cms\Filesystem\Contracts\FsInterface;
use CraftCms\Cms\Image\Contracts\AssetTransformerInterface;
use CraftCms\Cms\Image\Data\ImageTransform;
use CraftCms\Cms\Shared\Exceptions\NotSupportedException;
use GraphQL\Type\Definition\Type;

class MyServiceTransformer implements AssetTransformerInterface
{
public static function handle(): string
{
return 'my-service';
}

public static function displayName(): string
{
return 'My Service';
}

public static function gqlArguments(): array
{
return [
'blur' => [
'type' => Type::int(),
'description' => 'Blur amount for the transformed asset.',
],
];
}

public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string
{
if (! str_starts_with((string) $asset->getMimeType(), 'image/')) {
throw new NotSupportedException('Only images are supported.');
}

$settings = $imageTransform->settings;

$sourceUrl = $asset->getUrl();

if ($sourceUrl === null) {
// Use $asset->getStream() or $asset->getCopyOfFile() and upload the
// source somewhere the transformer can read from.
throw new NotSupportedException('A public source URL is required.');
}

return 'https://img.example.test/'.http_build_query([
'src' => $sourceUrl,
'w' => $imageTransform->width,
'h' => $imageTransform->height,
'blur' => $settings['blur'] ?? null,
]);
}

public function invalidateAssetTransforms(Asset $asset): void
{
// Purge remote transforms, delete generated files, or clear internal caches.
}

public function getTransformString(ImageTransform $imageTransform, bool $ignoreHandle = false): string
{
$parts = [
'w' => $imageTransform->width,
'h' => $imageTransform->height,
'format' => $imageTransform->format,
'blur' => $imageTransform->settings['blur'] ?? null,
];

return http_build_query(array_filter($parts, fn (mixed $value): bool => $value !== null));
}

public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
{
return null;
}

public function getFilesystemSettingsHtml(FsInterface $filesystem, bool $readOnly = false): ?string
{
return null;
}
}
```

## Runtime Behavior

`getTransformUrl()` should return the final URL for the transformed asset.

Use `NotSupportedException` when the transformer cannot handle the asset or transform options. Craft catches that exception when rendering asset URLs, logs it, and returns `null`.

The `$immediately` argument tells the transformer whether Craft wants the transform generated before returning the URL. Transformers that delegate to remote services may ignore it when the returned URL is enough to trigger generation externally.

## Cache Keys

`getTransformString()` defines the transformer's canonical cache key. Include only options that affect the generated result.

This is what lets one transformer support an option like `blur` while another transformer can safely ignore it. Ignored options stay in `ImageTransform::$settings`, but they should not be included in the transform string unless they affect output.

## Settings UI

Named image transforms can store transformer-specific settings. Return CP HTML from `getImageTransformSettingsHtml()` when your transformer needs fields on the named transform edit screen.

Filesystem defaults can also store transformer-specific settings. Return CP HTML from `getFilesystemSettingsHtml()` when your transformer needs fields on the filesystem edit screen. Craft's built-in `craft` transformer uses those filesystem-scoped settings for its transform filesystem and transform subpath.

Fields should post under `settings`.

```php
public function getImageTransformSettingsHtml(ImageTransform $imageTransform, bool $readOnly = false): ?string
{
$value = htmlspecialchars((string) ($imageTransform->settings['blur'] ?? ''), ENT_QUOTES);
$disabled = $readOnly ? ' disabled' : '';

return <<<HTML
<div class="field">
<div class="heading">
<label for="settings-blur">Blur</label>
</div>
<div class="input ltr">
<input type="number" id="settings-blur" name="settings[blur]" value="{$value}"{$disabled}>
</div>
</div>
HTML;
}
```

Craft preserves unsupported settings when the runtime transformer differs from the transformer that originally supplied the settings UI.

## GraphQL Arguments

Transformers may add transform arguments for GraphQL by returning definitions from `gqlArguments()`.

```php
public static function gqlArguments(): array
{
return [
'blur' => [
'type' => Type::int(),
'description' => 'Blur amount for the transformed asset.',
],
];
}
```

Argument names must not collide with Craft's built-in transform arguments or arguments registered by another transformer. Craft throws an `ImageTransformException` if a collision is detected.

## Filesystem Defaults and Named Transforms

Filesystems have a default transformer setting. Use that for environment-specific behavior, such as a cloud image service in staging and production but Craft's local transformer in development. The setting can be an environment variable, such as `$ASSET_TRANSFORMER`, whose value is a registered transformer handle.

Named image transforms can optionally select a transformer. Leave the named transform set to the filesystem default when the transform should follow the asset's filesystem.

Per-call `transformer` values still take precedence:

```twig
{{ asset.url({
transformer: 'my-service',
transform: 'hero',
blur: 8,
}) }}
```

## Filesystems With and Without URLs

Transformers should work with filesystems that have public URLs and filesystems that do not.

Craft's built-in `craft` transformer stores its transform output target in filesystem-scoped transformer settings. Its settings include the transform filesystem and transform subpath. When no transform filesystem is configured, Craft writes generated transforms to the asset's source filesystem.

Craft's built-in `craft` transformer returns direct filesystem URLs when possible and signed Craft action URLs for private filesystems. Custom transformers should follow the same principle: return a usable URL regardless of whether the source or configured transform filesystem has public URLs.

If your transformer needs to read the original asset and `Asset::getUrl()` returns `null`, use `Asset::getStream()` or `Asset::getCopyOfFile()` instead of requiring a public source URL.

## Legacy Image Transformers

The legacy image transformer API remains available through the Yii adapter layer for backwards compatibility. Legacy transformers registered through `craft\services\ImageTransforms::EVENT_REGISTER_IMAGE_TRANSFORMERS` are bridged into the asset transformer registry by their class name.

New code should implement `AssetTransformerInterface` and register a stable handle instead of registering a legacy image transformer class.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% import '_includes/forms.twig' as forms %}

{% namespace 'transformerSettings[craft]' %}
{{ forms.fsField({
label: 'Transform Filesystem'|t('app'),
instructions: 'Choose which filesystem image transforms should be stored in.'|t('app'),
id: 'transform-fs-handle',
name: 'transformFsHandle',
includeEnvVars: true,
value: old('transformerSettings.craft.transformFsHandle', settings.transformFsHandle ?? null),
options: [{label: 'Same as asset filesystem'|t('app'), value: null, data: {hint: ''}}]|merge(craft.cp.getFsOptions()),
disabled: readOnly,
showSelectedHint: true,
selectizeOptions: {
extraPlugins: false,
allowEmptyOption: true,
placeholder: 'Same as asset filesystem'|t('app'),
},
}) }}

{{ forms.autosuggestField({
label: 'Transform Subpath'|t('app'),
instructions: 'Where transforms should be stored on the filesystem.'|t('app'),
id: 'transform-subpath',
name: 'transformSubpath',
suggestEnvVars: true,
value: old('transformerSettings.craft.transformSubpath', settings.transformSubpath ?? null),
disabled: readOnly,
}) }}
{% endnamespace %}
32 changes: 23 additions & 9 deletions resources/templates/settings/assets/transforms/_settings.twig
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
id: 'name',
name: 'name',
value: old('name', transform.name),
errors: transform.errors.get('name'),
errors: errors.get('name'),
autofocus: true,
required: true,
disabled: readOnly,
Expand All @@ -64,11 +64,25 @@
autocorrect: false,
autocapitalize: false,
value: old('handle', transform.handle),
errors: transform.errors.get('handle'),
errors: errors.get('handle'),
required: true,
disabled: readOnly,
}) }}

{{ forms.selectField({
label: 'Transformer'|t('app'),
instructions: 'Which transformer should be used when this named transform is applied.'|t('app'),
id: 'transformer',
name: 'transformer',
options: transformerOptions,
value: old('transformer', transform.getTransformer() ?? ''),
disabled: readOnly,
}) }}

{% if transformerSettingsHtml %}
{{ transformerSettingsHtml|raw }}
{% endif %}

{% embed '_includes/forms/field.twig' with {
label: 'Mode'|t('app')
} %}
Expand Down Expand Up @@ -115,7 +129,7 @@
label: 'Fill Color',
name: 'fill',
value: mode == 'letterbox' and fill != 'transparent' ? fill,
errors: transform.errors.get('fill'),
errors: errors.get('fill'),
disabled: readOnly,
}) }}
</div>
Expand Down Expand Up @@ -148,7 +162,7 @@
name: "width",
size: 5,
value: old('width', transform.width),
errors: transform.errors.get('width'),
errors: errors.get('width'),
disabled: readOnly,
}) }}

Expand All @@ -158,7 +172,7 @@
name: "height",
size: 5,
value: old('height', transform.height),
errors: transform.errors.get('height'),
errors: errors.get('height'),
disabled: readOnly,
}) }}

Expand All @@ -167,13 +181,13 @@
id: 'upscale',
name: 'upscale',
on: old('upscale', transform.upscale ?? config('craft.general.upscaleImages')),
errors: transform.errors.get('upscale'),
errors: errors.get('upscale'),
disabled: readOnly,
}) }}

{% embed '_includes/forms/field.twig' with {
label: 'Quality'|t('app'),
errors: transform.errors.get('quality'),
errors: errors.get('quality'),
} %}
{% block input %}
{% import '_includes/forms.twig' as forms %}
Expand Down Expand Up @@ -221,7 +235,7 @@
{label: 'Partition'|t('app'), value: 'partition'},
],
value: old('interlace', transform.interlace ?? 'none'),
errors: transform.errors.get('interlace'),
errors: errors.get('interlace'),
disabled: readOnly,
}) }}

Expand All @@ -246,7 +260,7 @@
name: "format",
instructions: "The image format that transformed images should use."|t('app'),
value: format,
errors: transform.errors.get('format'),
errors: errors.get('format'),
options: formatOptions,
disabled: readOnly,
}) }}
Expand Down
Loading
Loading