glue-agency / craft-litespeed
LiteSpeed Cache (LSCache) integration for Craft CMS: tag-based purging, cache control and vary
Package info
github.com/glue-agency/craft-litespeed
Type:craft-plugin
pkg:composer/glue-agency/craft-litespeed
Requires
- php: ^8.2
- craftcms/cms: ^5.2.0
Requires (Dev)
- craftcms/ecs: dev-main
- phpunit/phpunit: ^10.1 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-02 13:17:09 UTC
README
Lets LiteSpeed Web Server's page cache (LSCache) cache Craft pages, and purges them the moment their content changes. A Craft port of LiteSpeed's own Laravel, Drupal and WordPress cache plugins, using the same response headers.
- Every cacheable page is sent with
X-LiteSpeed-Cache-Controland tagged with Craft's own element cache tags throughX-LiteSpeed-Tag. - Saving, deleting or moving an element purges exactly the pages that showed it, through
X-LiteSpeed-Purge. That also works from the queue and from console commands. - Logged-in users and cookie-dependent pages get their own cache variant through
X-LiteSpeed-Vary. - A CP section purges URLs or tags by hand, and so do the console and the Clear Caches utility.
- A utility shows how many pages were handed to LiteSpeed, and asks LiteSpeed whether a given URL is cached.
- Every setting can be managed in the CP, or per environment through
config/litespeed.php.
Requirements
Craft CMS 5.2 or later, PHP 8.2 or later, and a LiteSpeed Web Server (Enterprise or OpenLiteSpeed) with the cache module enabled. The plugin does nothing harmful without LiteSpeed; the headers are simply ignored.
Installation
composer require glue-agency/craft-litespeed php craft plugin/install litespeed
Turn on cache lookups in web/.htaccess:
<IfModule LiteSpeed> CacheLookup on RewriteEngine On RewriteRule .* - [E=Cache-Control:no-autoflush] CacheKeyModify -qs:utm* CacheKeyModify -qs:gclid CacheKeyModify -qs:fbclid </IfModule>
Then purge after every deploy, since new templates don't invalidate anything. Do it once the new release is
live: Deployer's Craft recipe runs clear-caches/all (which includes the LiteSpeed cache) before
deploy:publish, so the old release can re-cache its pages in between. With Deployer:
desc('Purges the LiteSpeed cache'); task('craft:litespeed_purge', craft('litespeed/purge/all', ['showOutput'])); after('deploy:publish', 'craft:litespeed_purge');
What gets cached
A response is cached when all of these hold, and is sent X-LiteSpeed-Cache-Control: no-cache otherwise:
- It's a GET or HEAD site request that a controller action rendered: no action request, CP request, preview or token.
- The visitor is a guest without an active session, and the response sets no cookies.
- The status is 200, or listed in
statusTtls. - The path matches none of the
excludeUris. - Its
Cache-Controldoesn't containno-store. Craft'ssetNoCacheHeaders()sends that, and Craft calls it itself for every page that outputs a CSRF token, and for pages that link to an image transform that hasn't been generated yet.
The Cache-Control header itself is left alone. It's meant for browsers and for caches downstream of
LiteSpeed, while LiteSpeed follows the plugin's X-LiteSpeed-Cache-Control, so private or no-cache in it
don't keep a page out of LiteSpeed. To keep one out, call setNoCacheHeaders() (which sends no-store) or
{% do craft.litespeed.noCache() %}.
A page whose HTML depends on a cookie needs that cookie in varyCookies, plus the .htaccess rule (see
Vary), before the plugin is enabled. Otherwise the first visitor's variant is served to everyone.
Craft's {% cache %} tags keep working: their element tags are passed on to LiteSpeed even when the block comes
from Craft's cache.
Forms and CSRF
A {{ csrfInput() }} makes a page uncacheable. Enable Craft's asynchronous CSRF inputs so forms stay
cacheable:
// config/general.php 'asyncCsrfInputs' => true,
or per form: {{ csrfInput({ async: true }) }}.
Purging
| Trigger | What's purged |
|---|---|
| Element saved, deleted, restored, moved or propagated | The pages that showed it, and the element's own URL |
| Section, entry type, volume, field, site or global set changed | Every page that queried that kind of element; for fields, sites and globals, the whole site |
| Drafts and revisions | Nothing |
| Craft's garbage collection | The whole site, unless purgeOnGc is off |
php craft litespeed/purge/all or Utilities → Clear Caches → LiteSpeed cache |
The whole site |
php craft litespeed/purge/all --everything |
The entire LiteSpeed cache, including other apps on the same vhost |
php craft litespeed/purge/urls <url>... [--subpages] |
Those URLs, and with --subpages everything below them |
php craft litespeed/purge/tags <tag>... |
The pages tagged with craft.litespeed.tag() |
LiteSpeed only accepts a purge on a response it serves. In a web request the purge rides along on the response itself. Console commands and queue jobs relay it with a request to each site's base URL; when that request fails, the purge is kept and the next web request sends it.
The relay request goes to the first site URL of each host, so it passes through LiteSpeed the way a visitor does. Both relay settings are config-file only:
loopbackUrlsreplaces those URLs. Only set it when LiteSpeed has to be reached on another URL than the site's base URL, for example because the base URL redirects: the relay doesn't follow redirects, and each URL must answer the POST itself with a 204.loopbackOptionsare Guzzle options for the request, such as basic auth on a staging site.
// config/litespeed.php return [ '*' => [], 'staging' => [ 'loopbackOptions' => ['auth' => ['dev', 'dev']], ], 'production' => [ 'loopbackUrls' => ['https://www.example.com/nl/'], ], ];
The relay is authenticated with Craft's securityKey, so the web and console processes need the same one.
The control panel
The LiteSpeed section purges one or more URLs, optionally with all their sub-pages. To purge a whole site, enter its base URL and tick "Purge all sub-pages". The Purge tags permission adds the Tags tab.
Utility
Utilities → LiteSpeed has two parts, behind Craft's own permission for the utility.
Pages sent to LiteSpeed for caching counts the plugin's own record, per site: every GET response it sent with
X-LiteSpeed-Cache-Control: public,max-age=N is stored with its URL (query string included), cookie variant, tags
and expiry. A purge removes the pages it reaches, and Craft's garbage collection removes expired ones. LiteSpeed
can't list what it holds, so this is what the plugin handed over, not what LiteSpeed kept: LiteSpeed can evict a
page early, refuse to store one, or be purged on the server (from the WebAdmin console, or by another app on the
vhost) without Craft knowing. URLs that .htaccess folds into one cache entry, such as ?utm_source=… visits,
are separate rows. HEAD requests are never recorded: LiteSpeed doesn't store their responses.
Check a page takes a full URL or a path, checks it, and lists the recorded pages for it; part of a URL only
searches the record. The check is the real status: it sends LiteSpeed a HEAD request, which it answers from its
cache without storing anything, with the loopbackOptions and without cookies, so it checks what a guest gets. The answer is
in the cache (x-litespeed-cache: hit), not in the cache right now (a public x-litespeed-cache-control
without a hit), never cached (no-cache), a redirect, or an error. Only URLs on the hosts of this install's
sites can be checked.
On a multi-domain install, a purge from a web request only reaches the vhost that served it (see Caveats), but it removes the matching rows for every host. The other domains' pages can then still be cached while the utility no longer lists them; the check shows the truth.
Vary
Logged-in users
LiteSpeed answers a cached page before Craft runs, so the no-cache a site sends to logged-in users only
helps on pages that aren't cached yet: on a cached URL a logged-in user gets the guests' copy. That's fine while
pages look the same for everyone. When they don't, turn on varyLoggedIn (off by default), or vary on a cookie
of your own that the site sets at login (see below).
With varyLoggedIn on, logged-in users get a _lscache_vary cookie when they log in, which is removed again
when they log out. LiteSpeed varies on that cookie name without being told to, so a logged-in user never gets a
guest's cached page. The cookie's name is the loggedInCookie setting; a name that doesn't start with
_lscache_vary needs a server rule.
Cookies of your own
When a page renders differently depending on a cookie of your own, cache a variant per cookie value. Either for the whole site:
// config/litespeed.php return [ 'varyCookies' => ['customer_group'], ];
or only on the pages that read it:
{% do craft.litespeed.varyCookie('customer_group') %}
That sends X-LiteSpeed-Vary: cookie=customer_group. Each of these cookies also needs a
server rule.
Server rules
LiteSpeed looks up a cached page before Craft runs, so it has to know the vary cookies up front.
LiteSpeed Web Server reads them from web/.htaccess, in Apache syntax. List every cookie in one rule,
separated by commas: E= sets the variable, so a second Cache-Vary rule overwrites the first instead of adding
to it. A loggedInCookie that doesn't start with _lscache_vary goes in the same list.
<IfModule LiteSpeed> RewriteRule .* - [E="Cache-Vary:customer_group,my_vary_cookie"] </IfModule>
OpenLiteSpeed reads the same rule, but only when the virtual host has Auto Load from .htaccess on, and it
picks up changes to .htaccess after a graceful restart.
nginx needs nothing. The cache runs inside LiteSpeed: an nginx proxy in front of it, as on Combell, passes the cookies through, and a server that only runs nginx has no LiteSpeed cache at all.
Templates
{# Keep this page out of the cache #} {% do craft.litespeed.noCache() %} {# Cache this page for an hour instead of the configured TTL #} {% do craft.litespeed.ttl(3600) %} {# Tag this page, to purge it by hand later on #} {% do craft.litespeed.tag(['news', 'footer']) %} {# Cache a variant per value of this cookie #} {% do craft.litespeed.varyCookie('customer_group') %}
A template that sets X-LiteSpeed-Cache-Control itself, with {% header %}, keeps it: the plugin only adds
its tags.
Settings
Everything below can be set under LiteSpeed → Settings, which admins see in the section's nav (Settings →
Plugins → LiteSpeed leads there too). The CP saves to project config, so it's only editable where allowAdminChanges is on; elsewhere admins see it read-only. To set a value per
environment, enter an environment variable such as $LITESPEED_ENABLED or $LITESPEED_PREFIX (enabled
and prefix accept one), or set it in config/litespeed.php, which overrides the CP: an overridden field is
shown read-only. The relay settings (loopbackUrls, loopbackOptions) and maxHeaderLength are config-file only.
| Setting | Default | |
|---|---|---|
enabled |
true |
Send any LiteSpeed headers at all |
prefix |
hash of the system UID | Prefix for every tag, letters and digits only. It keeps purges from reaching other apps on the same vhost, so it must never change between deploys |
ttl |
86400 |
Public cache lifetime, in seconds |
statusTtls |
[404 => 3600] |
Lifetimes for non-200 responses, by status code. Any other status isn't cached |
excludeUris |
[] |
Regular expressions matched against the path (without leading slash) |
varyCookies |
[] |
Cookies every page varies on |
varyLoggedIn |
false |
Give logged-in users their own cache variant |
loggedInCookie |
_lscache_vary |
Name of the logged-in users' vary cookie |
purgeStale |
false |
Serve the stale copy while a purged page regenerates |
purgeOnGc |
true |
Purge the whole site when Craft's garbage collection runs: php craft gc, a deploy, and about 1 in 100,000 requests that reach Craft (gcProbability) |
loopbackUrls |
each host's first site URL | Where console and queue purges are relayed to, when LiteSpeed has to be reached on another URL than the site's base URL. Config file only |
loopbackOptions |
[] |
Guzzle options for that relay request, merged over a 10 second timeout and no redirects. Config file only |
maxHeaderLength |
8000 |
Longest tag or purge header sent, in bytes. Config file only |
maxHeaderLength exists because servers cap header sizes and LiteSpeed doesn't document its limit. The only
data point is an unconfirmed OpenLiteSpeed report: a header line over 16 KB is dropped and the request hangs, more
than 64 KB of headers gives a 503. At about 15 bytes a tag, 8000 bytes is roughly 500 tags. A page with more tags
than fit swaps its per-element tags for per-type ones, so it's still purged, just by any change to that element
type; if even that doesn't fit, the page isn't cached. A purge that doesn't fit is split into parts that do: from
the console or the queue each part is its own relay request, and a web response carries the first part and leaves
the rest to a queue job. Both are logged. Only change it once the server's real limit is known.
Logging
Everything the plugin does goes to storage/logs/litespeed-<date>.log:
[INFO] Purging public,tag=…: the purge header sent with a response, from a CP save, a relay or the next request after a failed relay.[INFO] Relayed purge to <url>: a console or queue purge reached the site and came back with a 204.[ERROR] Couldn’t relay purge to <url>: …: the relay request failed (timeout, basic auth, a redirect). The purge is kept and sent with the next web request.[INFO] Purge too long for one response, the other N parts go out from the queue.: a big purge from a web request, split over a queue job.[WARNING] Too many cache tags for <path>, not caching it.: the page-sidemaxHeaderLengthfallback. A page that falls back to per-type tags is logged at[INFO].[WARNING] Couldn’t record <url> as sent to LiteSpeed: …: the utility's record of a page couldn't be written. The page itself is unaffected.
LiteSpeed itself doesn't acknowledge a purge, so the log shows what was sent, not what LiteSpeed did with it. To
confirm, request the page again: x-litespeed-cache: miss means it was purged.
Caveats
- Separate domains. A purge from a web request only reaches the LiteSpeed vhost that served that request. When the sites of a multi-site install are served by different vhosts, a save in the CP only purges the CP's vhost; the others catch up when their TTL runs out.
- Element API. An endpoint cached by Element API's own
cacheoption loses its element tags on Craft's cache hits, so LiteSpeed is never told to purge it. Set'cache' => falseon endpoints LiteSpeed caches. - Scheduled entries. An entry whose post or expiry date passes isn't saved, so nothing purges it. Run
craft update-statusesfrom cron (Craft 5.7+), which resaves them, or rely on the TTL. - Copied databases. The default prefix comes from the system UID, so a staging site built from a
production database shares production's prefix. That's harmless while each runs on its own vhost; set
prefixwhen two of them share one. - Not yet supported: private cache for logged-in users and ESI. Logged-in users are never cached. The design, and what we found out about LiteSpeed along the way, is in roadmap/esi.md.