Skip to content

Add the Craft Cloud asset transform driver - #213

Draft
timkelty wants to merge 5 commits into
5.xfrom
feature/craft-6-cloud-transforms
Draft

timkelty wants to merge 5 commits into
5.xfrom
feature/craft-6-cloud-transforms

Conversation

@timkelty

@timkelty timkelty commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Description

Craft 6 lets each disk choose an asset transformer with an asset_transformer key in config/filesystems.php. This adds a craft-cloud transform driver. It turns transforms into signed Cloudflare image URLs on Craft Cloud's asset CDN, and the Cloud gateway verifies those URLs before transforming. It's built on core's AssetTransformDriver contract rather than ported from 3.x. The only parts kept from 3.x are the external contracts: the URL format and signature the gateway verifies, and the Cloudflare options it accepts.

Using it. Add the key to the Cloud disks whose assets should be transformed on the CDN. Cloud doesn't add it for you:

'images' => ['driver' => 'craft-cloud', 'prefix' => 'images', 'asset_transformer' => 'craft-cloud'],

A scoped disk without the key follows the disk it wraps. A disk without the key uses Craft's own craft driver.

Which assets it transforms. The driver trusts the disk's choice. It transforms images (avif, gif, heic, heif, jpeg, jpg, png, svg, webp) and PDFs on any S3 disk that chooses it. Other file types throw NotSupportedException, so the CP shows file icons. So do GIFs and SVGs when transformGifs or transformSvgs is off.

Assets on disks that aren't S3 go to the craft driver with only the core parameters. Core can't hand a transform to another driver, so the driver calls AssetTransformers::driver('craft') directly. That covers a disk shared between environments that is local in development, the setup #212 documents.

An earlier revision also checked that the disk was in Cloud's bucket and wasn't the temporary-upload disk. I removed both checks:

  • The craft-cloud disk driver from Register Craft Cloud storage as Laravel disks #212 only ever builds S3 disks on the project's bucket, with CDN URLs. A bucket check can't tell a dev bucket from Cloud's, since both come from CRAFT_CLOUD_PROJECT_ID.
  • Transforms on the CDN work wherever the asset's own URL does. Where it doesn't, the disk's originals are already broken too.
  • Register Craft Cloud storage as Laravel disks #212's temporary-upload disk doesn't set asset_transformer, so temporary uploads only reach this driver if a project puts them on a disk that chose it.

Parameters. CloudflareOptions maps core parameters onto Cloudflare's options:

  • width, height and quality pass through.
  • mode and upscale map to fit. Without upscale, Craft's upscaleImages setting applies.
  • position maps to gravity. Crops use the asset's focal point instead, as the craft driver does.
  • fill maps to background for letterboxing.
  • jpg maps to baseline-jpeg, or to jpeg when interlaced.

Cloudflare options with no core equivalent are declared as parameterRules, so they're validated, and can be passed inline or set on named transforms under craft-cloud. They are anim, background, blur, border, brightness, compression, contrast, dpr, draw, flip, gamma, gravity, metadata, rotate, saturation, segment, sharpen, trim and zoom, plus the gateway's PDF page. An explicit gravity or background wins over the derived one. Cloudflare's own fit isn't declared, since mode and upscale cover all six values. There are no CP fields for these parameters, and undeclared parameters aren't forwarded.

Results. Each result reports its output width and height, worked out the same way the craft driver does it, because getImg(), getWidth($transform) and x srcset sizes read them. The MIME type comes from the requested format, or from the source when the gateway negotiates the format.

PDFs. PDFs default to format=auto. The gateway only rasterizes PDFs when a format is requested, and auto lets it pick AVIF or WebP. Without either, it falls back to PNG, which is the MIME type reported.

URLs and signing. AssetCdn builds the URL: the asset's URL with the options in its query, signed with HTTP Message Signatures over @path and @query, in the s parameter, keyed by CRAFT_CLOUD_SIGNING_KEY. It uses craftcms/http-message-signatures directly. 3.x's UrlSigner wrapper, its query normalization for older Craft URL encoding, and its verify() were only needed for 3.x's ESI URLs, so they're gone.

The gateway leaves v out of signatures, so the asset's revision is now added after signing when revAssetUrls is on, instead of being stripped. Transform URLs change when the file or focal point does. A test confirms that a URL still verifies after core's getThumbUrl() re-serializes it to add v.

RequestSigner is unchanged and stays shared for #214.

Possible gateway change. If the gateway rasterized PDFs without a requested format, defaulting to auto as it does for images, the driver's PDF special case would go away. I haven't found any other contract change that would make this meaningfully simpler.

Dependencies. This requires craftcms/cms ^6.0.0-beta.1. Packagist hasn't indexed craftcms/cms-assets 6.0.0-beta.1 yet, and core requires it at its own version, so the lock currently resolves to 6.x-dev at 05ce9b8. That's the commit the beta.1 tag points to. Once Packagist catches up, composer update craftcms/cms craftcms/cms-assets moves both to the tag. This PR also adds craftcms/http-message-signatures, league/uri, league/uri-components and guzzlehttp/guzzle.

Overlap with #212. Both PRs change composer.lock, so whichever merges second needs composer update --lock. The provider changes merge cleanly.

Related issues

timkelty and others added 4 commits November 6, 2026 21:13
Ports 3.x's signed Cloudflare transform URLs to Craft 6's asset transform
driver registry, and registers a craftCloud transformer profile that
becomes the default on Cloud.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b2a5-ffcf-7312-966f-7e8624cf8b79
Co-authored-by: Amp <amp@ampcode.com>
Core now picks transformers with a disk's asset_transformer key instead of
registered profiles (craftcms/cms#20542). Register the craft-cloud driver
with AssetTransformers, drop the craftCloud profile and the
defaultAssetTransformer switch, and detect Cloud storage from the asset's
own disk, so scoped disks work.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b2a5-ffcf-7312-966f-7e8624cf8b79
Co-authored-by: Amp <amp@ampcode.com>
#20542 merged without the per-call transformer argument, so hand files that
aren't in Cloud's bucket straight to the craft driver. Drop the branch pin
and lock to 6.x at 8a13417.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b2a5-ffcf-7312-966f-7e8624cf8b79
Co-authored-by: Amp <amp@ampcode.com>
Design the driver on core's contract instead of porting 3.x: map core
parameters onto Cloudflare options, declare only the Cloudflare-only ones,
report output dimensions, and use the focal point only for crops, like the
craft driver. Sign with http-message-signatures directly, without 3.x's
query normalization, and add the asset's revision after signing, since the
gateway leaves v out of signatures. Trust each disk's asset_transformer
choice instead of checking the bucket, and hand only non-S3 disks to the
craft driver. Require craftcms/cms ^6.0.0-beta.1.

Amp-Thread-ID: https://ampcode.com/threads/T-01a1b2a5-ffcf-7312-966f-7e8624cf8b79
Co-authored-by: Amp <amp@ampcode.com>
timkelty added a commit that referenced this pull request Nov 10, 2026
Tag pages with their Craft cache tags and derive CDN-Cache-Control from
the final response headers. Purge on every TagDependency invalidation,
except drafts and revisions, with one queued PurgeStaticCache job per
request, command or queue job, signed with #213's RequestSigner. Add a
Clear Caches option and a single cloud:purge command.

Require craftcms/cms ^6.0.0-beta.1.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-6551416a-ce7c-4e90-adac-8c2edc72248c
timkelty added a commit that referenced this pull request Nov 10, 2026
Tag pages with their Craft cache tags and derive CDN-Cache-Control from
the final response headers. Purge on every TagDependency invalidation,
except drafts and revisions, with one queued PurgeStaticCache job per
request, command or queue job, signed with #213's RequestSigner. Add a
Clear Caches option and a single cloud:purge command.

Require craftcms/cms ^6.0.0-beta.1.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-6551416a-ce7c-4e90-adac-8c2edc72248c
timkelty added a commit that referenced this pull request Nov 10, 2026
Tag pages with their Craft cache tags and derive CDN-Cache-Control from
the final response headers. Purge on every TagDependency invalidation,
except drafts and revisions, with one queued PurgeStaticCache job per
request, command or queue job, signed with #213's RequestSigner. Add a
Clear Caches option and a single cloud:purge command.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-6551416a-ce7c-4e90-adac-8c2edc72248c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant