Skip to main content

Documentation

No results found.
Features

Responsive Image Pipeline

Every image on a WebProCMS site is delivered as a responsive <img srcset="…" sizes="…"> tag automatically. The browser picks the right variant for the device, the variant is generated lazily on first request, and e...

Every image on a WebProCMS site is delivered as a responsive <img srcset="…" sizes="…"> tag automatically. The browser picks the right variant for the device, the variant is generated lazily on first request, and every variant is served as WebP at quality 80. Editors never have to think about image sizes.


The problem

A typical CMS workflow is: editor uploads a 4000px-wide hero photo from their phone, drops it into a row, ships it. On a 412px-wide phone, the browser downloads all 4000 pixels — five megabytes of image data to fill a 412-pixel slot. On a hi-DPI desktop, it downloads the same five megabytes for a 552-pixel column. The visual result is identical to a properly-sized image; the bandwidth bill, the LCP score, and the mobile data plan are not.

The traditional fixes are all painful:

  • Manually export each image at three or four widths, upload them all, and write srcset/sizes by hand. Editors won't do this, and shouldn't have to.
  • Bake a fixed set of widths into the CMS and pre-generate all of them on upload. Wastes disk on widths nobody ever requests, and gets it wrong the first time anyone changes a layout.
  • Pick "one big size" and hope. Standard answer; the answer that makes the bandwidth bill what it is.

WebProCMS fixes this end-to-end so the editor sees a single "Pick from library" button and the runtime handles the rest.

The fix

Every image-bearing component in the design library — x-dl.image, x-dl.media, x-dl.gallery, x-dl.slider — emits a responsive <img srcset="…" sizes="…"> automatically. The component declares the widths it needs and a sizes expression matching the row's actual layout; the runtime signs Glide URLs for each width, the browser asks for the one it needs, and Glide generates that variant on first request and caches it on disk.

Every variant is WebP at quality 80, regardless of the source format. Originals are capped at 2400px wide on upload via ImageResizer::resizeToMaxWidth so the source itself stays bounded. Variants are generated lazily — never speculatively — so disk only holds widths the public site has actually requested.

The result is that every image gets exactly the right size for every breakpoint, with no editor effort and no upfront work on upload.

How it works

Per-spot widths and sizes declarations

Every image spot in a row template declares three widths and a precise sizes expression that matches the row's actual layout. The widths and sizes are read by the component, fed to GlideUrl::srcsetFromResolved, and emitted as the srcset/sizes pair on the rendered <img>.

<x-dl.media slug="__SLUG__"
    widths="824,964,1104"
    sizes="(min-width: 1248px) 552px, (min-width: 768px) calc(50vw - 48px), calc(100vw - 48px)"
    field-wrapper-classes="rounded-base overflow-hidden aspect-video" ... />

The three widths are chosen to cover three real-world cases:

Width Target
First (e.g. 824) Precise mobile — ~412px viewport at DPR 2.
Second (e.g. 964) Middle band — covers iPad-portrait class viewports.
Third (e.g. 1104) Precise desktop hi-DPI — slot CSS pixels × 2.

The original is auto-appended at its actual stored width as a high-DPR fallback, so a hi-DPI desktop viewport that exceeds the third width can still find an entry. No template work needed for that — srcsetFromResolved looks up the original's width from MediaItem::metaByPathCached() and adds it.

Why sizes matters as much as srcset

The sizes attribute tells the browser, in advance of loading the image, how many CSS pixels the slot will occupy at the current viewport width. Without it, the browser assumes 100vw and picks the variant closest to the full viewport — which is almost always wrong for anything inside a constrained container.

A 552px image slot inside a max-w-container row on a 1920px hi-DPI desktop, with no sizes attribute, would otherwise pull a 1920px-wide image — roughly four times more pixel data than the slot needs. A precise sizes matching the row's grid math fixes this.

The CLAUDE.md "Image performance & srcset" section walks through how to compute widths/sizes for a new row from its container max-width, grid columns, and gap.

Lazy variant generation via Glide

GlideUrl::signVariant — the one emitter — builds a signed URL of the shape /img/v/{path}/{spec}.{ext}?s={signature}, e.g. /img/v/2026/07/foo.jpg/w824-q85-v1712345678.webp. The signature is HMAC'd with glide.sign_key so attackers can't enumerate widths to burn cache and CPU.

The URL path is the file path. That variant lives at public/img/v/2026/07/foo.jpg/w824-q85-v1712345678.webp, so the FIRST request reaches PHP (which encodes it and writes it there) and every request after it is served by the webserver with no framework boot at all — on nginx, Apache and OpenLiteSpeed alike, because all three try real files before falling through to index.php. GlideVariantPath is the single source of truth for that grammar; GlideController is the miss path and nothing else.

The variants for an image are still co-located in one directory keyed by the source path, so per-image invalidation stays a single deleteDirectory() call — the v/ namespace segment lives in the disk ROOT, not the disk-relative path, so every eviction site was untouched by the move.

Why the v/ segment exists. It keeps /img/{source} from ever being a real directory. Legacy URLs are /img/{source}?w=…, and both front controllers refuse to route an existing directory to PHP — Apache/OLS via RewriteCond %{REQUEST_FILENAME} !-d, nginx via try_files $uri $uri/. Without the segment, every old URL still sitting in cached HTML would have 403'd instead of reaching the redirect that rescues it.

Server config is zero on the happy path, with three things worth knowing.

  • nginx sends no Cache-Control on a static hit unless a location ^~ /img/ block adds one — Dashboard → Settings → Caching hands out the correct snippet, and it is in the README. Unconfigured is not uncached: nginx still sends ETag and Last-Modified, so a browser revalidates with a conditional request and gets a 0-byte 304. Never a refetch, and never PHP. OpenLiteSpeed needs nothing: RunCloud's generated vhost carries expires with image/*=A31536000, so variants get a one-year header for free (verified live — a static image on an OLS install returns cache-control: public, max-age=31536000 with no .htaccess involvement, which is just as well since OLS ignores headers from .htaccess).
  • That block must be ^~, and it must try_files back to the front controller. A variant filename ends in .webp/.avif, so any location ~* \.(jpg|png|webp|avif)$ block matches it, and nginx prioritises regex locations over ordinary prefix ones — ^~ is what outranks them. A matching block without try_files serves warm variants happily and 404s every variant that has not been generated yet, which looks like "some images are broken" rather than a config fault.
  • Flat-layout installs need img/ in the FlatHtaccess allowlist (and its byte-identical twin in public/install.php), or the default-deny rule 403s every variant on its second request — it works once, then stops.

The effective quality is always part of the path, even when it is only the global default. GLIDE_QUALITY used to be applied server-side and left out of the URL, which meant changing it re-encoded every variant while every URL stayed byte-identical — so browsers, CDNs and LSCache all went on serving the old bytes from a URL that no longer described them, with nothing short of a purge-everything able to correct it. Signing it makes the URL fully describe the bytes it returns, which is the property the edge layers depend on (Performance and caching, Tier 11).

One canonicalizer serves both sides. Warm-ahead callers pass sparse params — the real-estate photo cache asks for ['w' => 824] and lets the defaults supply the rest — while emitted URLs carry them explicitly; GlideVariantPath::canonicalize() merges glide.defaults for both, so the two spellings name one file. A second merge implemented anywhere else would be free to disagree, and the symptom would be a warmer burning CPU on variants no request ever resolves to.

Anything the grammar cannot express throws rather than being dropped, because a silently-ignored param would collapse two visually different variants onto one path and whichever generated first would win for both.

Cache busting after a Replace

Editors can replace a media library file in place — same path, new bytes. The browser would happily reuse the old cached copy if URLs were stable, so the runtime appends ?v={updated_at_unix} to every image URL:

  • MediaItem::url() appends ?v= to the source URL using the row's updated_at timestamp.
  • GlideUrl::srcsetFromResolved signs v as a Glide parameter so it survives signature validation and produces a unique URL per updated_at.

A Replace updates media_items.updated_at, which changes every URL the page emits, which forces browsers to refetch. No CDN purge or manual cache-busting is needed.

Glide variants cleared when source replaced

The media_items.deleted Eloquent hook clears the entire variant directory for that path:

Storage::disk(config('glide.cache'))->deleteDirectory($item->path);

This runs on delete; replace-in-place is handled the same way through the picker's replace flow. Either way, the next public render regenerates only the variants that are actually requested — variants for widths nobody asks for any more never come back.

Originals capped at 2400px on upload

ImageResizer::resizeToMaxWidth runs on every upload to the media library. If the source is wider than 2400px, it's resampled in place via GD with format-appropriate quality settings (JPEG q85, WebP q85, PNG/GIF lossless). Originals already at or below 2400px are left untouched.

This bounds the source pool. Without the cap, a 6000px-wide phone photo would mean 6000-pixel original sitting on disk forever, served as-is on the rare hi-DPI viewport that asks for it. With the cap, the original tops out at 2400px — already smaller than most hi-DPI desktop slots × 2 — and the responsive variants below it stay proportional.

Path-keyed metadata cache

The public render path needs the original's width and updated_at timestamp to build srcsets. A naive implementation would SELECT from media_items once per image per page render — fine on a small page, painful on a 50-image gallery.

MediaItem::metaByPathCached() reads the entire path → {width, updated_at_ts} map in a single query and Cache::rememberForevers it. The model's saved and deleted hooks Cache::forget the key, so the next render reissues the query and the cache rewarms. Public renders on a warm cache issue zero queries to media_items, regardless of how many images the page contains.

Pass-through for non-resizable inputs

GlideUrl is safe to call unconditionally — components don't need to feature-detect. The helpers pass through unchanged for:

  • Empty paths (return empty string).
  • External URLs (http://, https:// not under the storage prefix).
  • SVG files (resizing a vector at fixed widths is meaningless).
  • Inputs where the URL doesn't resolve to a media-disk path.

In each case the component still emits an <img src="…">; it just skips srcset/sizes since there's nothing to vary.

Media library thumbnails reuse cached variants

The dashboard media library grid and editor picker thumbnails don't mint new widths — they reuse a variant the public site has already generated, via GlideUrl::smallestUrl (fallback 824px, the typical mobile width every public site builds first). Preview modals use largestUrl (fallback 1200px). The "Copy URL" button still serves the original storage URL.

They get there by picking the smallest (or largest) STANDARD_WIDTHS step below the original and signing the exact URL shape srcsetFromResolved emits — same version token, same delivery params. The variant directory is deliberately not scanned: it is a grab-bag of widths from OG crops, legacy sizes and editor thumbnails, so "smallest cached" bears no relationship to what the srcset actually serves.

Emitting the identical URL is a requirement, not a tidiness preference. x-dl.section puts largestUrl() in a hero poster's <img src> while its srcset comes from srcsetFromResolved, and preloads one of them in <head>. A URL differing by so much as a q param still resolves to the right bytes, but the browser treats it as a second resource — an extra fetch, and an LCP preload matching nothing it goes on to request.

This keeps the dashboard cheap: the thumbnails it displays are already on disk because the public site rendered them, and resolving one costs no filesystem work at all — the width and version come from the cross-request media-metadata cache, and the ladder step is arithmetic.

AVIF <source> ahead of the WebP <img>

For JPEG and WebP sources, image components emit a <picture> wrapper with an AVIF <source> before the <img>, built by GlideUrl::avifSrcsetFromResolved. AVIF-capable browsers take it; everything else falls to the WebP <img>. On a real listing photo AVIF at q70 came in 26–36% under the WebP the same page was serving.

It is deliberately limited to .jpg/.jpeg/.webp sources. Photographs are where AVIF's saving shows up, and neither format carries alpha to lose. PNG stays on the WebP path — PNGs are overwhelmingly logos, screenshots and alpha graphics, where a lossy AVIF re-encode wrecks sharp edges and flat colour for little gain. A keep-original image (optimize = false) opts out of AVIF too — it asked for its source format.

WebP was originally excluded on the assumption that an already-modern format had little left to give. Measured on a real 1536px hero that was wrong — AVIF ran ~35% smaller at every rung — so the gate was widened. The PNG exclusion is a separate, still-current decision; don't fold the two together.

Which components emit it

Coverage is per-component, not automatic: a component only offers AVIF if it calls avifSrcsetFromResolved and wraps its <img> in <picture class="contents">. class="contents" is load-bearing — it makes the wrapper layout-neutral so aspect-* / object-cover on the surrounding element and the image resolve exactly as they did before the wrapper existed.

Surface AVIF
x-dl.image (incl. its album carousel branch) yes
x-dl.media yes
x-dl.gallery (featured banner + grid tiles) yes
x-dl.slider yes
x-dl.section bg-video poster yes
x-dl.section background image yes, via image-set() — see below
components/gallery thumbnails yes
Real-estate Listing, automotive Vehicle photos yes
Richtext inline images (ShortcodeProcessor::rewriteInlineImages) yes
x-dl.logo, x-dl.logos no — PNG/SVG at 320–640px, where the saving is noise

Richtext inline images are the one place where class="contents" is doing real work rather than just being tidy: the <img> carries its own .bc-img-left/.bc-img-right float (or .bc-img-center's margin: auto) plus an inline percentage width. Because the <picture> generates no box, all three keep resolving against the prose column exactly as they did unwrapped — which is why the float/width attributes must stay on the <img> and never move to the wrapper.

CSS background images use image-set(), not <picture>

A section background is a CSS background-image, so it can carry only one URL and has no <picture> to offer alternatives through. GlideUrl::largestAvifUrl() is the AVIF twin of largestUrl() — same ladder step, signed fm=avif — and x-dl.section emits two declarations:

background-image: url('…webp…');
background-image: image-set(url('…avif…') type('image/avif'), url('…webp…'));

Both parts are deliberate. The plain declaration comes first so a browser that can't parse image-set() still paints a background rather than none at all. Only the AVIF entry is typed — an untyped entry counts as always-supported, so the fallback stays correct whatever Glide serves for it. type() uses single quotes because the string is rendered into a double-quoted HTML attribute.

largestAvifUrl() returns '' (offer nothing) under avifSrcsetFromResolved's conditions plus one of its own: when the image is smaller than every ladder step, largestUrl() serves the raw original rather than a Glide variant, and there is no AVIF counterpart to that.

The matching LCP preload switches to type="image/avif" when AVIF is on offer. A typed preload is skipped wholesale by browsers that can't decode the type, so an AVIF-incapable browser gets no preload rather than a wasted second download — the same trade the eager x-dl.image claim already makes.

The style string must stay ;-terminated. The solid/gradient background branches append their --section-bg-* custom properties to the same attribute separated by a space only. An unterminated background-image declaration swallows them and the browser drops both. Guarded by a test in AvifVariantsTest.

Is AVIF actually on? (it is invisible otherwise)

An install that can't encode AVIF looks identical to one that can — it just serves WebP forever. Two places now say so out loud:

  • The installer's success screen (installer_avif_support() in public/install.php) reports supported/not with the driver detail and, when off, what to ask the host for. It mirrors canEncodeAvif() rather than calling it, so the two must be kept in step.
  • Dashboard → Tools → Image Cache carries an AVIF on/off badge with the same detail.

AVIF requires ImageMagick 7, and the gate is not cosmetic. GlideUrl::canEncodeAvif() refuses AVIF unless Imagick::getVersion() reports IM7+, because IM6's 2021-era heif delegate is broken in two ways that were re-measured on a production box:

  • It ignores the quality option — encoding the same JPEG at q35/50/65/80 produced byte-identical output.
  • Its AVIF encode flattens the alpha channel.

When the gate is false, avifSrcsetFromResolved returns '', no <picture> is emitted, and the page is byte-for-byte the plain WebP <img> it always was. Degradation is silent and total — there is no half-enabled state.

The quality-API asymmetry (do not "simplify" this)

IM7's coders read quality from opposite places, which is invisible until you sweep it:

Format setCompressionQuality() (image_info) setImageCompressionQuality() (image)
AVIF / heic honoured ignored
WebP ignored honoured

Intervention Image's AvifEncoder happens to call both, and its WebpEncoder calls the image-level one, so the pipeline is correct as shipped. But any raw-imagick code that encodes AVIF must call setCompressionQuality() — setting only setImageCompressionQuality() produces AVIF at a fixed default quality regardless of the requested q, which looks exactly like the IM6 bug.

10-bit AVIF for gradients

8-bit AVIF posterises smooth dark gradients worse than the WebP it is served ahead of, which would make enabling AVIF a visible regression on gradient-heavy heroes. On a real hero with a large dark-grey ramp, the gradient panel carried 73 tonal steps at 8-bit versus 103 in the WebP fallback; at 10-bit it carried 285.

Intervention's AvifEncoder exposes only quality and strip, so there is no supported way to request 10-bit. Depth is a property of the underlying Imagick image though, and Intervention does not reset it — so setting it immediately before the parent encodes survives through to getImagesBlob(). GlideAvifEncoder does exactly that, wired in through Glide's ServerFactory encoder config key (see GlideController).

Measured cost across a dozen real encodes: +1.6% bytes, +38% encode time. The CPU is one-time per variant — Glide caches to disk and serves with immutable/1-year headers — so it lands on the first request for a width and never again. Depth is glide.avif_depth (GLIDE_AVIF_DEPTH, default 10); set it to 8 to opt out on a CPU-constrained host. Non-8/10/12 values are ignored rather than producing broken variants, and every failure in the encoder is swallowed — a depth tweak must never 500 an <img>.

Judging banding: use an amplified visual, not unique-colour counts. identify -format %k rewards WebP for block-edge artifacts that add distinct values while looking worse — it ranked 8-bit AVIF below WebP when the amplified view showed the opposite. Compare with -colorspace Gray -auto-level -sigmoidal-contrast 20x50%.

Native width is in the AVIF srcset, and must stay there

The AVIF list is not a subset the browser can overflow out of. <picture> commits a browser to the chosen <source>'s srcset and does not fall back to the <img>'s candidates. So while the WebP path can lean on its appended native-width entry, AVIF needs its own — otherwise AVIF-capable clients on large or hi-DPR viewports get a smaller file upscaled while WebP clients get native width.

avifSrcsetFromResolved therefore appends a native-width entry as a signed AVIF variant (never the raw JPEG — wrong MIME for an AVIF source), guarded on glide.max_width because the controller 404s anything above it.

Operational: the extension is ABI-locked and package-owned

Two things bite on a self-managed host where IM7 was built from source:

  • The compiled imagick.so is tied to one PHP API version. A build for PHP 8.4 (API 20240924) fails to load on PHP 8.5 with undefined symbol: empty_fcall_info. Moving a site to a newer PHP means it loads the distro's IM6-linked imagick and quietly drops back to WebP until the extension is rebuilt for that version (phpize/php-config from the new PHP, --with-imagick=/opt/im7).
  • The distro package owns the file. lsphp84-imagick owns …/lib/php/20240924/imagick.so, so a package update overwrites a hand-built IM7 extension with the IM6 one — silently, with AVIF just disappearing. Hold the package (apt-mark hold) and keep a re-apply copy of the build outside dpkg's reach.

Link the IM7 build with -Wl,-rpath,/opt/im7/lib so the extension resolves its own libraries; that keeps other IM6 consumers on the system untouched and avoids an /etc/ld.so.conf.d entry that could repoint them.

A third trap has nothing to do with the extension: an encode-parameter change does not invalidate a CDN. Variant URLs are signed over w/fm/q/v/s, so anything that changes the bytes without changing one of those — bit depth, encoder flags, an ImageMagick upgrade — leaves the URL byte-identical and every edge and browser copy valid. glide:clear empties the origin's disk cache and cannot touch the edge.

Quality is the one that used to belong on this list and no longer does: GLIDE_QUALITY is now signed into the URL (see "Lazy variant generation" above), so changing it busts every layer on its own. Bit depth and encoder flags still need a manual purge. Measured after enabling 10-bit: Cloudflare served 8-bit at 11,958 B (cf-cache-status: HIT, age: 1717) while the origin served 10-bit at 12,163 B for the same URL. Purge the CDN for /img/* after such a change, and verify against the origin (curl --resolve <host>:443:127.0.0.1) rather than the public URL.

Full server procedure — building IM7 and the extension, rebuilding after a PHP upgrade, and the swap ordering that avoids caching a stale render — is in imagemagick7-avif-server-build.md.

What lives where

Path Purpose
app/Support/Media/GlideUrl.php URL signing, srcset building, AVIF srcset + largestAvifUrl (CSS backgrounds) + canEncodeAvif gate, smallest/largest cached-variant lookup, path-from-URL extraction.
app/Support/Media/GlideAvifEncoder.php Glide encoder subclass that raises AVIF variants to glide.avif_depth bits.
app/Support/Media/ImageResizer.php Source-side resize on upload (resizeToMaxWidth) and WebP re-encode for the AI pipeline (convertToWebp).
app/Models/MediaItem.php URL accessor with ?v= cache-buster, path-keyed metadata cache, variant directory cleanup on delete.
config/glide.php Sign key, source disk, cache disk.
resources/views/components/dl/image.blade.php etc. Image-bearing components that consume widths/sizes and call GlideUrl::srcsetFromResolved.

Notes

  • "Every variant is WebP at q80" applies to the pipeline output's baseline layer. Source files are stored in their original format (subject to the 2400px cap); resized variants are WebP, plus an AVIF layer for JPEG sources on IM7 installs (see above). Actual quality comes from glide.q (85) and glide.avif_quality (70).
  • AVIF quality numbers are not comparable to WebP's. At a matched q=60 AVIF measured 8% larger than WebP on a real photo; the win only appears once the scales are calibrated against each other, which is why avif_quality is a separate key with its own default and a comment warning against drifting it low.
  • The original is appended to srcset at its actual width, not at a synthetic "max" width — so a hi-DPR viewport that needs more than the largest declared width still has a real entry to choose.
  • The variant directory layout (public/img/v/{path}/{spec}.{ext}) means cache scope is per-source-image, not per-width-bucket. Replacing one image clears that one image's variants and nothing else.
  • Adding image-generation support to a new component requires only widths and an aspect-* utility on the wrapper class — the AI pipeline reads the same declarations the public srcset uses, so token spend matches the rendered slot. See the AI image generation section in CLAUDE.md for details.