On this page
LiteSpeed Cache for Laravel Applications
Learn how to wire LiteSpeed Cache into a Laravel application on Kualo hosting, covering the integration package, middleware, cache tags, and selective purging.
This article explains how to integrate LSCache into Laravel using the official Composer package, configure middleware for different route groups, and purge cached content selectively using cache tags.
Before you start, confirm that APP_ENV=production and APP_DEBUG=false are set in your .env file. Debug mode can suppress or corrupt caching headers, so caching will not behave predictably until those values are correct. LiteSpeed Cache should also be enabled in cPanel for your account.
How LSCache works with Laravel
LiteSpeed Cache operates at the web server layer, sitting between the visitor and your application. Every response Laravel generates passes back through LiteSpeed on its way to the visitor. As it does, LiteSpeed reads specific response headers - primarily X-LiteSpeed-Cache-Control - and decides whether to store a copy. On subsequent requests for that URL, LiteSpeed checks its cache first and, on a hit, serves the stored copy directly without Laravel booting at all.
Laravel has no built-in awareness of LSCache. Integration is done in application code by emitting the headers LiteSpeed expects. The practical way to do this is through the official litespeed/lscache-laravel Composer package, which provides two middlewares and a facade:
- The
lscachemiddleware controls theX-LiteSpeed-Cache-Controlheader - whether a route is cached, publicly or privately, and for how long. - The
lstagsmiddleware applies cache tags via theX-LiteSpeed-Tagheader, so groups of pages can be purged together. - The
LSCachefacade handles purging.
For background on what LSCache is and how it differs from application-level caches, see what is LiteSpeed Cache? and public cache vs. private cache.
Installation and setup
Kualo servers have Composer pre-installed. If you have not used it on your account before, see how to use Composer on Kualo servers.
- Connect to your account via SSH and navigate to your Laravel project root.
- Install the package:
composer require litespeed/lscache-laravel
Laravel's package auto-discovery registers the middlewares and facade automatically - there is nothing to add to your application code or middleware configuration.
- Publish the package configuration file:
php artisan vendor:publish --provider="Litespeed\LSCache\LSCacheServiceProvider"
This creates config/lscache.php in your project.
- Enable cache lookup in your
public/.htaccessfile, as a separate block alongside Laravel's standard rewrite rules:
<IfModule LiteSpeed>
CacheLookup on
</IfModule>
This tells LiteSpeed to check the cache for incoming requests. Without it, responses may be stored but never served from cache.
Configuration defaults
The package is safe by default: until you configure it or apply middleware, no X-LiteSpeed-Cache-Control header is sent and nothing is cached. The defaults in config/lscache.php can be controlled from your .env file:
LSCACHE_DEFAULT_TTL- the default max-age in seconds. Defaults to0, which means the header is not sent at all.LSCACHE_DEFAULT_CACHEABILITY-public,private,no-cache, orno-vary. Defaults tono-cache.LSCACHE_ESI_ENABLED- enables ESI globally. Defaults tofalse.LSCACHE_GUEST_ONLY- restricts caching to guest visitors only. Defaults tofalse.
These defaults apply to any route that does not carry its own lscache middleware. A sensible setup leaves the defaults conservative and opts routes into caching explicitly, as below.
Middleware and cache-control headers
Apply the lscache middleware to routes or route groups. The parameter is a directive string using the same values as the X-LiteSpeed-Cache-Control header, separated by semicolons.
Caching public routes
The example below caches public pages for one hour:
Route::middleware(['lscache:max-age=3600;public'])->group(function () {
Route::get('/', [HomeController::class, 'index']);
Route::get('/about', [PageController::class, 'about']);
Route::get('/products/{slug}', [ProductController::class, 'show']);
});
You can override the group setting on an individual route by applying the middleware again with different values - the route-level middleware wins:
Route::middleware(['lscache:max-age=3600;public'])->group(function () {
Route::get('/', [HomeController::class, 'index']);
Route::get('/stock-levels', [StockController::class, 'index'])
->middleware('lscache:max-age=60;public'); // changes frequently
});
How long should the TTL be? If your application purges the cache when content changes (covered below), the TTL is a safety net and can be long - hours or a day. If you are relying on the TTL alone for freshness, keep it short. Our custom PHP applications article covers this trade-off in more detail.
Preventing caching for authenticated routes
Any route that serves user-specific content must never be served from the public cache. Set private or no-cache for authenticated areas:
Route::middleware(['auth', 'lscache:max-age=120;private'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/account', [AccountController::class, 'show']);
});
Route::middleware(['auth', 'lscache:no-cache'])->group(function () {
Route::get('/checkout', [CheckoutController::class, 'show']);
});
If a logged-in user's response is accidentally cached publicly, LiteSpeed may serve that response to other visitors. Always apply private or no-cache to any route that reads from the session, checks authentication, or returns user-specific data.
For a deeper explanation of when to use each cache type, see public cache vs. private cache.
Separating guests and logged-in users on shared routes
The middleware works well when public and private content live on different routes. The harder case is a route that serves everyone - a product page, say - where logged-in users see a slightly different version. To keep separate cache copies, add a cache vary on a cookie that identifies logged-in users, using a rewrite rule in public/.htaccess:
<IfModule LiteSpeed>
RewriteEngine On
RewriteRule .* - [E=Cache-Vary:logged_in]
</IfModule>
The cookie should be one that exists only for authenticated users. Laravel does not set one by default, so create a simple marker cookie on login and clear it on logout - its value can be as simple as 1. With the vary in place, LiteSpeed stores one cache copy for requests without the cookie (guests) and separate copies keyed on its value for requests that have it.
Do not vary the cache on laravel_session or XSRF-TOKEN. Laravel issues these cookies to every visitor, including guests, with a unique value each. Varying on them gives every single visitor their own private copy of every page, and your public cache hit rate drops to effectively zero. The vary cookie must be one that guests do not have.
Alternatively, if logged-in users are a small minority and the personalised differences don't matter to you, set LSCACHE_GUEST_ONLY=true and serve cache to guests only.
Cache tags and selective purging
Cache tags let you label cached responses so that groups of pages can be purged together when content changes. Updating a product should invalidate that product's page and any listing pages that feature it, without touching the rest of the site.
Tagging routes with the lstags middleware
The lstags middleware applies tags to everything in a route or group. Separate multiple tags with semicolons:
Route::middleware(['lscache:max-age=3600;public', 'lstags:products'])->group(function () {
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{slug}', [ProductController::class, 'show']);
});
Every cached response from this group now carries the products tag, so all of them can be purged in one operation.
If a privately cached route needs to carry tags that refer to public content, prefix those tags with public::
->middleware(['lscache:max-age=120;private', 'lstags:public:products;wishlist'])
Dynamic per-record tags
Middleware parameters are fixed strings defined with the route, so they cannot include a model ID. For per-record tags such as product-42, set the X-LiteSpeed-Tag header directly on the response in your controller:
public function show(Product $product)
{
return response()
->view('products.show', compact('product'))
->header('X-LiteSpeed-Tag', implode(',', [
'products',
'product-' . $product->id,
'category-' . $product->category_id,
]));
}
This is the same header the middleware emits - you are just building the values at runtime. Tags must be ASCII with no spaces or quotes.
Purging by tag
The LSCache facade provides the purge methods. To purge all cached responses carrying given tags:
use LSCache;
LSCache::purgeTags(['product-' . $product->id, 'products']);
A natural home for this is an Eloquent observer, so the purge fires whenever the model is saved through any code path:
namespace App\Observers;
use App\Models\Product;
use LSCache;
class ProductObserver
{
public function updated(Product $product): void
{
LSCache::purgeTags(['product-' . $product->id, 'products']);
}
public function deleted(Product $product): void
{
LSCache::purgeTags(['product-' . $product->id, 'products']);
}
}
Register the observer in a service provider:
Product::observe(ProductObserver::class);
Purging works by attaching an X-LiteSpeed-Purge header to the HTTP response, which LiteSpeed processes as the response passes through it. This means purges only work inside a web request - for example, an admin saving a product through your application. Queued jobs, scheduled tasks, and artisan commands run in the CLI, produce no HTTP response, and their purge calls do nothing. If content changes happen outside web requests, route the purge through a lightweight internal HTTP endpoint instead.
Purging by URL and purging everything
You can also purge specific URIs, or the whole cache:
LSCache::purgeItems(['/blog', '/about-us', '/']);
LSCache::purgeAll();
URI purges match exact paths only, which is why tag-based purging is usually the better tool. Use purgeAll() sparingly - typically after a deployment - because it forces LiteSpeed to rebuild every page from scratch.
Stale purging: built-in stampede protection
By default, the package's purges use LiteSpeed's stale mode. When a purged page is requested by several visitors at once, only the first request regenerates the page through Laravel; the others are briefly served the old cached copy until the fresh one is ready. Without this, a purge on a busy page would send every concurrent visitor through PHP simultaneously.
A few seconds of stale content is invisible on most sites and the protection is valuable, so leave it on. If a particular purge genuinely cannot tolerate any staleness, pass false as the second parameter:
LSCache::purgeTags(['stock-levels'], false);
For advanced cases, LSCache::purge() also accepts raw purge strings: LSCache::purge('private, tag=users') purges private cache entries by tag, public and private purges can be combined in a single call, and a ~s suffix on an individual tag controls stale behaviour per tag. See the package documentation for the full syntax.
Artisan cache versus LSCache
Running php artisan cache:clear clears Laravel's application cache (your configured cache driver, such as the filesystem or Redis). It does not touch the LSCache page cache at the server level. During debugging you may need to clear both:
php artisan cache:clear # clears Laravel's application cache only
Then purge LSCache from your application code with LSCache::purgeAll(), or from cPanel if a purge tool is available for your account.
CSRF tokens on publicly cached pages
This is the most important Laravel-specific issue to handle. Every Laravel form includes a CSRF token tied to the visitor's session, normally rendered with @csrf. If you publicly cache a page containing a form, you cache one visitor's token and serve it to everyone. Form submissions then fail with 419 Page Expired for every visitor whose session does not match the cached token. The same applies to the csrf-token meta tag used by JavaScript-driven applications.
The fix is to keep the page publicly cached but fetch the token per visitor. First, add a route that returns just the token, cached privately:
Route::get('/csrf', function () {
return response(csrf_token(), 200);
})->middleware('lscache:private;max-age=900');
Private caching gives each visitor their own cached copy. The 15-minute TTL has a useful side effect: Laravel sessions expire after 120 minutes of inactivity by default, so a token refresh every 15 minutes keeps the session alive for active visitors, without hitting PHP on every page view.
There are then two ways to get the token into the page.
Option 1: JavaScript (recommended)
Ship the page with an empty token and fill it in after load:
<meta name="csrf-token" content="">
<script>
fetch('/csrf')
.then(r => r.text())
.then(token => {
document.querySelector('meta[name="csrf-token"]')?.setAttribute('content', token);
document.querySelectorAll('input[name="_token"]').forEach(el => el.value = token);
});
</script>
This keeps the cache logic simple - the page is fully public, the token endpoint is private - and it works identically behind a CDN. The only trade-off is that the form is not submittable for the moment before the fetch completes.
Option 2: ESI
ESI renders the token server-side inside the publicly cached page, so it is present in the initial HTML. Replace @csrf in your forms:
<form method="POST" action="/profile">
@if(config('lscache.esi_views'))
<input type="hidden" name="_token" value='<esi:include src="/csrf" cache-control="private"/>'>
@else
@csrf
@endif
</form>
And the meta tag:
@if(config('lscache.esi_views'))
<meta name="csrf-token" content='<esi:include src="/csrf" cache-control="private"/>'>
@else
<meta name="csrf-token" content="{{ csrf_token() }}">
@endif
Add the toggle to your published config/lscache.php:
'esi_views' => env('ESI_ENABLED', false),
Do not call env('ESI_ENABLED') directly in Blade views, even though some examples online show this. Once you run php artisan config:cache - which you should in production - env() returns null for anything not read through a config file, and the toggle silently stops working. Always read environment values through config().
For ESI to work, two rules apply. First, esi=on must be set in the lscache middleware for every page that contains ESI blocks - without it, LiteSpeed does not parse the tags and your visitors will see the raw <esi:include> markup in the page:
Route::get('/contact', function () {
return view('contact');
})->middleware('lscache:max-age=3600;public;esi=on');
Second, do not enable ESI globally via LSCACHE_ESI_ENABLED - parsing every response for ESI tags has a performance cost, so switch it on only for the routes that need it.
A note on ESI more generally
CSRF tokens are the most common reason to reach for ESI in Laravel, but the same hole-punching technique works for any small personalised fragment on an otherwise public page - a greeting, a basket count. Our custom PHP applications article explains the concepts and trade-offs, including why a JavaScript call to an uncached endpoint is usually the simpler default.
Converting an existing application with an AI coding tool
Retrofitting LSCache into an existing Laravel application touches a lot of files at once: route definitions, controllers, observers, Blade templates for CSRF handling, config, and .htaccess. An AI coding tool such as GitHub Copilot, Cursor, Claude or ChatGPT can generate a solid working starting point for the whole conversion. The key is giving it enough context, and being precise about the package's syntax, because the most common AI failure here is inventing plausible-looking methods that do not exist in the package.
For a conversion like this, strongly consider an AI tool with direct access to your codebase - GitHub Copilot in your IDE, Cursor, or Claude Code with your repository open. The tool needs to see your actual routes, models, and templates to assign cache policies sensibly. Describing a whole Laravel application in a chat window is possible, but an AI that can read the code directly will produce more accurate and immediately usable output.
Here is a prompt you can adapt. Replace everything in square brackets with the details of your own application - the setup block is where most of the value comes from, so do not paste it verbatim:
I have an existing Laravel application hosted on LiteSpeed Web Server
(Enterprise edition, on a cPanel hosting account). I want to integrate
LiteSpeed Cache (LSCache) using the official litespeed/lscache-laravel
Composer package.
My application:
- Laravel version: [e.g. 11]
- Routes overview: [e.g. public marketing pages, a product catalogue
at /products, a blog at /blog, an authenticated area under /account,
and a checkout at /checkout]
- Authentication: [e.g. standard session auth via Laravel's auth
middleware]
- Content model and invalidation: [e.g. products belong to categories;
saving a product should invalidate its page, its category pages, and
the /products listing]
- Forms on publicly cached pages: [e.g. a contact form and a
newsletter signup - the CSRF token needs handling]
- Personalised fragments on public pages: [e.g. the header shows a
basket count for logged-in users; handle via a JavaScript call to a
no-cache endpoint / via ESI / not applicable]
- Frontend stack: [e.g. Blade with Vite, no Livewire or Inertia]
Please produce:
1. Route middleware assignments using the package's exact syntax:
"lscache:max-age=N;public", "lscache:max-age=N;private", and
"lscache:no-cache", with directives separated by semicolons. Apply
public caching as broadly as possible; use private or no-cache only
where the response genuinely depends on the session or user.
2. Cache tags using the "lstags" middleware for route groups, plus
dynamic per-record tags set in controllers via
->header('X-LiteSpeed-Tag', ...). Tags must be ASCII with no spaces
or quotes. If a privately cached response carries tags referring to
public content, prefix those tags with "public:".
3. Eloquent observers that purge with LSCache::purgeTags([...]) when
models change, granular enough that one save does not purge
unrelated pages. Purge headers only work inside web requests - flag
any of my content changes that happen in queued jobs, scheduled
tasks, or artisan commands, and propose a lightweight internal HTTP
endpoint for those, because purges from the CLI silently do
nothing.
4. CSRF handling for the forms above, using a /csrf route with
"lscache:private;max-age=900" and the token injected by
[JavaScript fetch / ESI]. If ESI: add esi=on to the lscache
middleware on every page that contains ESI blocks, do not enable
ESI globally, and gate Blade toggles through config(), never env(),
so they keep working after php artisan config:cache.
5. Recommended config/lscache.php and .env values (LSCACHE_DEFAULT_TTL,
LSCACHE_DEFAULT_CACHEABILITY), keeping the default conservative
(no-cache) so caching is opt-in per route.
6. The .htaccess additions: "CacheLookup on" in an <IfModule LiteSpeed>
block kept separate from Laravel's standard rewrite rules, plus a
cache vary rewrite rule ONLY if I need separate cache copies for
logged-in users on shared routes - varying on a marker cookie set
at login, never on laravel_session or XSRF-TOKEN.
Also include:
- A short explanation of which routes you made public, private, or
no-cache, and why.
- TTL reasoning: long max-age (hours or more) wherever tag purging
guarantees freshness, short TTLs only where no purge path exists.
- Leave the package's default stale purging enabled unless I say
otherwise.
- The syntax above comes from the litespeed/lscache-laravel README
(https://github.com/litespeedtech/lscache-laravel) and LiteSpeed's
Laravel documentation
(https://docs.litespeedtech.com/lscache/lsclaravel/). Follow it
exactly, and clearly flag anything where you are uncertain or have
deviated from it.
Things to tell the AI about your specific application
The more context you provide, the more accurate the output will be. Consider adding:
- Your full route list - paste
routes/web.phpor the output ofphp artisan route:list, so cache policies are assigned per route rather than guessed. - Where content changes happen - an admin panel within the app, Laravel Nova or Filament, queued imports, scheduled syncs. This determines where purges can fire and where the internal endpoint workaround is needed.
- Whether you use Livewire or Inertia - both complicate full page caching. Livewire components make per-session requests and embed component state in the page; Inertia responses include shared props such as the authenticated user. Tell the AI so it can exclude or restructure those pages rather than caching broken state.
- Your JavaScript setup - Vite, a legacy asset pipeline, or jQuery - so the CSRF injection snippet matches how your frontend is built.
Reviewing the output
AI-generated code is a starting point, not a finished product. Before deploying, check the following:
- Middleware strings use the package's semicolon syntax (
lscache:max-age=3600;public), and every facade call is a method that actually exists:purge,purgeAll,purgeItems,purgeTags. - No route that reads the session, checks authentication, or returns user-specific data ends up with
publiccacheability. - Purges fire after successful writes, inside web requests only, and tags are granular enough that routine saves do not purge the whole site.
- Any cache vary uses a login-only marker cookie, never
laravel_sessionorXSRF-TOKEN. - CSRF actually works: from a fresh browser session, load a publicly cached page that you know is a cache hit, submit its form, and confirm you do not get a 419 error.
- Pages are genuinely caching and purging as expected, using the response header verification method.
.htaccess considerations
Laravel uses .htaccess in the public/ directory to rewrite all requests through index.php. The standard Laravel rewrite block is fully compatible with LiteSpeed and must not be modified - LiteSpeed reads .htaccess natively, so the rules work exactly as they would on Apache.
Add your LSCache directives (CacheLookup on from the setup steps, plus any cache vary rules) as a separate <IfModule LiteSpeed> block, clearly separated from the Laravel rewrite rules. Servers that don't run LiteSpeed simply ignore the block.
.htaccess changes take effect immediately on LiteSpeed without any server restart. You can test the effect of a change straight away by inspecting response headers in your browser's developer tools or with curl -I.
For general guidance on .htaccess syntax and troubleshooting, see troubleshooting .htaccess issues.
Setting the correct document root
Laravel's public/ directory must be the document root for your domain. If cPanel is pointing the domain at the project root instead, visitors will see a directory listing or a 403 error, and LSCache will not cache anything useful because requests will not reach index.php.
To check or change the document root for a domain, use the Domains tool in cPanel and update the document root to point at public_html/your-project/public (or wherever your public/ directory sits relative to your account root). If you are unsure how to adjust this, see how to use the Domains tool in cPanel.
PHP version and memory settings
Laravel's minimum PHP version requirements change with each major release. Use the Select PHP Version tool in cPanel (powered by CloudLinux PHP Selector) to choose a compatible PHP version and adjust settings such as memory_limit and max_execution_time if your application needs them. See how to manage the PHP version in cPanel using the Select PHP Version tool for step-by-step instructions.
Verifying that caching is working
Once the middleware is in place, make two requests to a public route and inspect the response headers:
curl -sI https://yourdomain.com/ | grep -i litespeed
The first request will typically show X-LiteSpeed-Cache: miss (the page was generated and stored); the second should show X-LiteSpeed-Cache: hit. If you see neither header, check that LSCache is enabled in cPanel, that CacheLookup on is present in .htaccess, and that the route is not returning a private or no-cache directive. See verify if LiteSpeed Cache is working for a full walkthrough.
You can also use LiteSpeed's online LSCache Check Tool for a quick external yes/no answer with the response headers displayed. If your site sits behind QUIC.cloud CDN you may see X-QC-Cache headers instead of X-LiteSpeed-Cache - that also indicates caching is working.
For a broader look at integrating LSCache into a custom PHP application outside a framework, see using LiteSpeed Cache with custom PHP applications.
If you get stuck integrating LSCache into your Laravel application, raise a support ticket and our team can help you review your configuration.