A {{ cfimage }} Statamic tag, shaped to feel like {{ glide }}, that serves
and transforms images through a Cloudflare Worker (R2 binding + Workers
Images binding) instead of Glide/GD on the app server. No image processing
or image bytes ever pass through your Statamic site.
Glide (and Laravel's own Image facade) both do the resize/encode
work on your own server, on request. This addon moves that entirely to
Cloudflare's edge: the Worker reads the source straight out of R2 and
transforms it there, so origin CPU and bandwidth are never in the path for
image variants after the first request.
This addon is built around two things being true of your infrastructure:
- Your zone (domain) is on Cloudflare — the Worker is deployed as a route on that zone, so Cloudflare has to be managing DNS for it.
- Your asset originals live in Cloudflare R2 — the Worker reads the source file directly out of R2 itself. Assets stored elsewhere (S3, local/public disk, another CDN) aren't supported today.
If you haven't used Cloudflare before, three separate things need to exist
before {{ cfimage }} will serve anything:
- R2 — Cloudflare's object storage. Your asset files themselves (originals) need to actually live here. This addon doesn't upload anything for you — you point a normal Laravel filesystem disk at R2 and Statamic uploads to it exactly as it would to local disk or S3.
- A Worker — a small script Cloudflare runs at the edge, published by
this package into your app under
workers/cfimage/. It receives the signed URLs this addon builds, reads the source file out of R2, resizes it via the Workers Images binding, and returns the result. - This package — builds signed
{{ cfimage }}URLs pointing at your Worker, and gives youphp artisan cfimage:publishto keep the Worker's own config (wrangler.toml, preset table) in sync withconfig/cfimage.phpso nothing is maintained in two places.
- A Cloudflare account, with the domain you'll serve images from added to Cloudflare (i.e. Cloudflare is managing its DNS).
- R2 enabled on the account — the R2 dashboard tab will prompt you to enable it the first time you visit, no separate paid plan required for R2 itself.
- Cloudflare Images / Workers Images enabled on the account — this is the part that actually resizes images at the edge, and it's a separate product from R2. Check the "Images" tab in the dashboard before your first deploy; on some accounts this requires a paid plan.
composer require thoughtco/statamic-cloudflare-images
php artisan vendor:publish --tag=cfimage-config #this publishes config/cfimage.php
php artisan vendor:publish --tag=cfimage-worker #this publishes workers/cfimage/- In the Cloudflare dashboard, go to R2 → Create bucket and give it a
name (e.g.
my-app-assets). - Create an API token scoped to R2: R2 → Manage API tokens → Create API token, with read/write access to that bucket. This gives you an Access Key ID and Secret Access Key — save both, the secret is only shown once.
- Your Cloudflare Account ID is shown in the right-hand sidebar of almost every page in the dashboard — you'll need it for the R2 endpoint below and again later for the Worker.
- Add an R2 disk in
config/filesystems.php, using Laravel's S3 driver (R2 is S3-compatible):'r2' => [ 'driver' => 's3', 'key' => env('R2_ACCESS_KEY_ID'), 'secret' => env('R2_SECRET_ACCESS_KEY'), 'region' => 'auto', 'bucket' => env('R2_BUCKET'), 'endpoint' => env('R2_ENDPOINT'), // https://<account_id>.r2.cloudflarestorage.com 'use_path_style_endpoint' => true, ],
- Point a Statamic asset container (or your Spatie Media disk, etc.) at
this
r2disk. This is the diskconfig('cfimage.disks')will reference in step 2 — the addon reads existing assets from it, it doesn't migrate anything for you.
Open config/cfimage.php and set, at minimum:
disks— every disk{{ cfimage }}may serve from, mapped to an arbitrary R2 binding name the Worker will use, e.g.['assets' => 'ASSETS']. Each key must be a disk that exists inconfig/filesystems.phpand has abucketvalue set (ther2disk from step 1, for example).presets— named shortcuts in the same shape as Statamic's Glide presets, e.g.'thumbnail' => ['w' => 300, 'h' => 300, 'q' => 80, 'fm' => 'webp']. You can literally copy/paste them from the assets config file.
See the comments in that file for the rest of the options.
From your app root, the published Worker source lives in workers/cfimage/.
- Install dependencies (this installs
wranglerlocally — every command below runs it vianpx, no global install needed):cd workers/cfimage npm install - Authenticate wrangler so it's allowed to deploy to your account:
This opens a browser to authorize against your Cloudflare account. For CI or other non-interactive environments, set a
npx wrangler loginCLOUDFLARE_API_TOKENenvironment variable instead (dashboard → My Profile → API Tokens → Create Token, the "Edit Cloudflare Workers" template covers it) — wrangler picks this up automatically and skips the browser flow. - Point your chosen hostname at Cloudflare. Decide on the hostname
{{ cfimage }}URLs will use (e.g.images.example.com) and add a DNS record for it in the Cloudflare dashboard, proxied (orange cloud on). The record's target doesn't matter — the Worker's route intercepts requests to this hostname before they'd reach any actual origin. Set this hostname asCFIMAGE_URLin your app's.env(https://images.example.com). - Fill in the Worker deploy settings in
config/cfimage.php'sworkerarray:zone_name— your domain as it appears in Cloudflare (e.g.example.com), set viaCFIMAGE_ZONE_NAME.account_id— only needed if the account authenticated in step 2 has access to more than one Cloudflare account; otherwise wrangler infers it and this can stay blank. It's the Account ID from step 1.3, set viaCFIMAGE_ACCOUNT_ID.bucket_namevalues are generated automatically fromconfig('cfimage.disks')andconfig/filesystems.php— nothing to fill in by hand here, but the R2 bucket(s) themselves must already exist (step 1).
- Generate a signing secret — any random string works, e.g.:
Set this value in both places, exactly matching, or every URL will 404:
openssl rand -hex 32(run fromnpx wrangler secret put CFIMAGE_SIGNING_KEYworkers/cfimage/) and asCFIMAGE_SIGNING_KEYin your app's.env. - Generate and deploy the Worker. From your app root:
This regenerates
php artisan cfimage:publish --deploywrangler.tomland the preset table fromconfig/cfimage.php, then runswrangler deploy. (Run without--deployto just regenerate the files without deploying.)
config/cfimage.php is the single source of truth for the URL path prefix,
presets, disks, and Worker deploy settings — bucket names come from
config/filesystems.php, so nothing bucket-related needs duplicating by
hand. Never hand-edit wrangler.toml or src/presets.generated.js in the
published workers/cfimage/ directory — both are fully overwritten any
time you change config/cfimage.php and re-run:
php artisan cfimage:publish # regenerate only
php artisan cfimage:publish --deploy # regenerate + wrangler deploy
The signature, preset/modifiers, and source key all live in the URL path, not the query string:
<CFIMAGE_URL>/<path>/<disk>/<sig>/<preset-or-modifiers>/<key...>
e.g. https://images.example.com/cfimage/assets/<sig>/p_thumbnail/photo.jpg
or, without a preset, .../w_300,h_200,fit_crop/photo.jpg. Keeping these in
the path (rather than query params) means a CDN cache rule or proxy that
strips/ignores query strings can't silently drop the signature or collapse
distinct variants onto one cache entry.
Every failure path (bad signature, unknown disk, unresolvable modifiers,
missing source file) returns a bare 404 to the client — the specific
reason is only ever logged server-side (console.error, visible via
wrangler tail or Cloudflare Logs), so a prober can't use the response
itself to learn which part of a forged or malformed URL is wrong.
Mirrors {{ glide }}'s shape:
{{ cfimage:image preset="thumbnail" }}{{ url }}{{ /cfimage:image }}
<img src="{{ cfimage:image preset="thumbnail" }}" alt="...">
{{ cfimage src="{ mobile_image ?? image }" preset="thumbnail" }}Accepts a Statamic Asset, or any object exposing a public $disk property
and a getPathRelativeToRoot() method (e.g. a Spatie Media model), or a
plain path string (resolved against cfimage.default_disk).
dpris applied by multiplying the requested width/height before the resize — an approximation, not a pixel-for-pixel match of Glide's own dpr handling.