Kualo / docs
On this page

Using LiteSpeed Cache with Custom PHP Applications

LiteSpeed Cache is not just for WordPress or Joomla - you can wire it into any custom PHP application running on your Kualo hosting account. This article explains the core concepts, walks through the .htaccess configuration options, covers cache purging, and suggests a practical approach for generating a working integration with an AI coding tool.

21 min read Updated 10 Jun 2026

LiteSpeed Cache is not just for WordPress or Joomla - you can wire it into any custom PHP application running on your Kualo hosting account. This article explains the core concepts, walks through the .htaccess configuration options, covers cache purging (the part that requires real development work), and suggests a practical approach for generating a working integration with an AI coding tool.

This is an advanced topic aimed at developers. Creating integrations with LS Cache for custom code is beyond standard support and this is here for reference purposes only.

How LiteSpeed Cache works for custom applications

LiteSpeed Cache (LSCache) operates at the web server level, sitting in front of your PHP application entirely. When a cacheable response is stored, LiteSpeed serves subsequent matching requests straight from its cache without executing any PHP at all. This is what makes it so fast.

For applications that have an official LSCache plugin - WordPress, Joomla, Drupal, PrestaShop, and others - the plugin handles cache control and eviction automatically. For everything else, you have two tools available:

  • .htaccess directives and rewrite rules - tell LiteSpeed which URLs to cache, for how long, and under what conditions. This is straightforward and requires no PHP code.
  • LiteSpeed response headers sent from PHP - let your application decide cacheability per response, assign cache tags, and purge cached entries programmatically. This is where the real integration work lives.

The two can be combined, and there is a clear order of precedence: if your application sends an X-LiteSpeed-Cache-Control response header, it overrules whatever your rewrite rules say for that response. This means you can set broad defaults in .htaccess and make fine-grained decisions in PHP.

On Kualo hosting the LiteSpeed cache engine is already active at server level, but needs to be turned on in your control panel. If you want to confirm the engine is enabled for your account first, see How to enable LiteSpeed Cache in cPanel.

For the authoritative reference, see the LSCache developer guide and the no-plugin configuration guide.

Enabling caching via .htaccess

All site-level cache configuration goes in the .htaccess file in your document root (public_html or the relevant subdirectory). Directives must be wrapped in <IfModule LiteSpeed> tags so they are ignored on any server that does not run LiteSpeed.

Enable public caching for all URLs

<IfModule LiteSpeed>
  CacheEnable public /
</IfModule>

This caches every URL under your document root publicly - meaning the same cached copy is served to all visitors. It is a good starting point for mostly static pages, but for anything with forms, logins or user-specific content you should be selective, as below.

Enable caching with a TTL using rewrite rules

The CacheEnable directive cannot set a TTL. To control how long pages are cached, use a rewrite rule with the max-age value instead:

<IfModule LiteSpeed>
  RewriteEngine On
  RewriteRule .* - [E=cache-control:max-age=300]
</IfModule>

This caches every URL for 300 seconds (five minutes). Adjust the value to suit how frequently your content changes.

Choosing a TTL

How long you should cache for depends entirely on whether you build cache purging (covered later in this article):

  • If you are using .htaccess rules only, the TTL is your only invalidation mechanism. When you publish a change, visitors keep seeing the old version until the TTL runs out. Keep it short - somewhere between 60 and 600 seconds for most sites. Short TTLs still deliver most of the benefit: at even modest traffic levels, a 60-second TTL means each page hits your application at most once per minute no matter how many visitors arrive, which is exactly what you need to survive a traffic spike.
  • If your application purges the cache when content changes, the TTL stops being your freshness mechanism and becomes a safety net against purges that never fired. You can set it long with confidence - hours or days, even a week - because anything that changes is purged immediately anyway. Long TTLs mean stable pages might be served from cache for their entire lifetime off a single backend hit.

In other words: short TTL or purge engine, pick one. A long TTL without purging serves stale content; a short TTL with purging throws away cached pages that were still perfectly fresh.

If you need content to update the moment it changes and you are not in a position to build purging into your application, a short TTL is the honest compromise. Even 60 seconds of staleness is invisible on most sites, and the protection against load spikes is the same.

Cache only specific paths

You will almost always want to be selective. Caching is opt-in, so the safest pattern is to write rules that opt in only the requests you want cached, and explicitly mark sensitive paths as no-cache as a belt-and-braces measure:

<IfModule LiteSpeed>
  RewriteEngine On

  # Never cache admin, login, or cart pages
  RewriteCond %{REQUEST_URI} ^/(admin|login|register|cart|checkout)
  RewriteRule .* - [E=cache-control:no-cache]

  # Cache GET and HEAD requests from visitors without a session
  RewriteCond %{REQUEST_METHOD} ^(GET|HEAD)$
  RewriteCond %{HTTP_COOKIE} !session_token
  RewriteCond %{REQUEST_URI} !^/(admin|login|register|cart|checkout)
  RewriteRule .* - [E=cache-control:max-age=300]
</IfModule>

Any request that matches no rule simply is not cached, so there is no need to disable caching globally first.

Do not use CacheDisable public / as a "default off" mechanism if you intend to control caching from PHP or from later rewrite rules. CacheDisable switches the cache engine off for everything at or below that path, which means your cache-control rules and response headers will be ignored. If you want a fail-safe default, send no-cache from your application by default instead.

Private caching for logged-in users

Private cache stores a separate copy per visitor, which is appropriate for personalised pages:

<IfModule LiteSpeed>
  RewriteEngine On
  RewriteCond %{REQUEST_METHOD} ^(GET|HEAD)$
  RewriteCond %{HTTP_COOKIE} session_token
  RewriteCond %{REQUEST_URI} !^/(admin|checkout)
  RewriteRule .* - [E=cache-control:private,max-age=300]
</IfModule>

You can combine both blocks - public cache for guests, private cache for authenticated users - in the same .htaccess file.

LiteSpeed builds the private cache key from the visitor's IP address and session cookie. It automatically recognises a list of well-known session cookie names, including PHPSESSID, frontend, xf_session and lsc_private. If your application uses a custom cookie name (such as session_token in the examples here), tell LiteSpeed to vary on it explicitly - either with a rewrite rule [E=Cache-Vary:session_token] or, from PHP, the X-LiteSpeed-Vary: cookie=session_token response header. Note that auto-detected cookie values must be at least 16 bytes long to be included in the private cache key.

Cache vary: serving different versions of a URL

If your application serves different content to mobile and desktop visitors from the same URL, use a vary value to store separate cache copies:

<IfModule LiteSpeed>
  RewriteEngine On

  RewriteCond %{HTTP_USER_AGENT} "iPhone|Android|Mobile"
  RewriteRule .* - [E=cache-control:vary=ismobile]

  RewriteCond %{REQUEST_METHOD} ^(GET|HEAD)$
  RewriteCond %{REQUEST_URI} !^/(admin|login)
  RewriteRule .* - [E=cache-control:max-age=300]
</IfModule>

Your rewrite rule's mobile detection must exactly match your application's own mobile detection logic. A mismatch will cause the wrong version of a page to be cached and served to visitors.

Disabling caching for specific URLs

To exclude a path from caching, set no-cache:

<IfModule LiteSpeed>
  RewriteEngine On
  RewriteCond %{REQUEST_URI} ^/(admin|login|register|cart)
  RewriteRule .* - [E=cache-control:no-cache]
</IfModule>

Understanding public vs. private cache

Before writing your rules, it is worth being clear on the distinction. Our article on public cache vs. private cache explains this in detail, but in short:

  • Public cache - one copy served to all visitors. Use it for pages that look the same to everyone.
  • Private cache - one copy per visitor. Use it for pages that contain user-specific data.
  • No cache - never store the response. Use it for forms, checkout flows, and anything that writes data.

Public cache is where almost all of the performance benefit lives. One copy serves every visitor, so a single backend hit can absorb thousands of requests. Private cache is far weaker by comparison: every visitor has to warm their own copy, so the first view of each page per visitor is always a backend hit, and cache hits only happen on repeat views of the same page by the same person.

This leads to the most important design decision in any LSCache integration: aim to make as much of your site publicly cacheable as possible, even for logged-in users, and deal with the small personalised parts separately. A page is usually 95% identical for everyone - it is the "Hello, Jo" in the header, a basket count, or a logout link that forces the whole page into private cache or no-cache. There are two well-established techniques for cutting those parts out of the public page, covered next.

Punching holes in public pages: ESI and AJAX

If a page is mostly the same for everyone but contains a few personalised fragments, you do not have to give up public caching. You can cut "holes" in the publicly cached page and fill them in separately, using either ESI or client-side JavaScript.

Edge Side Includes (ESI)

ESI lets you mark out regions of a page that LiteSpeed assembles separately on every request. The outer page is cached publicly, while each ESI block is fetched on its own and can have its own cache policy - private, no-cache, or even public with a different TTL.

In your page template, replace the personalised fragment with an ESI include tag pointing at a URL that renders just that fragment:

<esi:include src="/fragments/user-greeting.php" />

Then tell LiteSpeed to process ESI for the page, either with a response header from PHP:

header('X-LiteSpeed-Cache-Control: public,max-age=300,esi=on');

or with a rewrite rule in .htaccess:

RewriteRule .* - [E=esi_on:1]

The fragment endpoint (/fragments/user-greeting.php in this example) is a normal PHP script that outputs just the HTML for that region, and sends its own cache headers to define that block's policy.

This is the key concept with ESI: every block has its own cache policy and its own TTL, completely independent of the page it sits in. A single publicly cached page can contain a privately cached greeting and a never-cached basket count, each with a different lifetime. The fragment endpoint declares which it wants:

// Always fresh - re-fetched from your application on every page view.
// This is the equivalent of the "TTL 0" setting in LiteSpeed's CMS plugins.
header('X-LiteSpeed-Cache-Control: no-cache');

// Or: cached per visitor with its own TTL, independent of the outer page
header('X-LiteSpeed-Cache-Control: private,max-age=600');
header('X-LiteSpeed-Vary: cookie=session_token');

Give each block as much caching as it can tolerate. A basket count probably has to be no-cache; a greeting only changes when the user logs in or out, so a private cache with a generous TTL means even the fragment is usually served from cache.

The result: a logged-in user requests a page, LiteSpeed serves the outer shell from public cache without touching PHP, and only the small greeting fragment runs through your application (or is served from that user's private cache). No flash of generic content, no JavaScript required, and the personalised content is present in the initial HTML.

Each ESI block that is not cached is a separate request to your application on every page view. A page with one or two holes is a big win; a page with fifteen uncached ESI blocks can end up slower than not caching at all. Keep holes few and small, and give fragments their own private cache where possible.

The AJAX alternative

The other approach is to keep the page entirely public and static, and load the personalised parts with JavaScript after the page renders. The page ships with a placeholder, and a small script calls a JSON endpoint that returns the user-specific data:

// /api/user-state.php - always fresh, never cached
header('X-LiteSpeed-Cache-Control: no-cache');
header('Content-Type: application/json');
echo json_encode([
    'name'        => $user->name ?? null,
    'basketCount' => $basket->count(),
]);
<span id="greeting">Welcome</span>
<script>
fetch('/api/user-state.php')
  .then(r => r.json())
  .then(d => {
    if (d.name) {
      document.getElementById('greeting').textContent = 'Hello, ' + d.name;
    }
  });
</script>

This is often the simplest and most robust option for custom applications: there is no ESI markup to learn, the technique works identically behind a CDN, and the cache logic stays trivially simple - the whole page is public, one endpoint is no-cache. The trade-offs are that personalised content appears a moment after the page renders (a brief flash of the generic version), and it depends on JavaScript, so it is not suitable for anything search engines need to see.

Choosing an approach

For most custom applications, AJAX is the better default. The reasons are practical:

  • The cache logic stays trivially simple. The whole page is public, one endpoint is no-cache. There is no per-fragment cache policy to design, no interaction between the outer page's cache state and the fragment's, and far less to get wrong.
  • Nothing new to learn or maintain. It is a fetch call and a JSON endpoint - patterns every PHP developer already knows. ESI introduces its own markup, its own processing rules, and a dependency on the server assembling your pages.
  • It is portable. The same pattern works identically if you later put a CDN in front of the site, move hosts, or run the application somewhere without LiteSpeed. ESI ties your templates to the server.
  • It is easier to debug. You can see the endpoint call in browser dev tools and test it in isolation. Diagnosing why an ESI fragment is rendering stale content means reasoning about server-side cache state you cannot directly inspect.

Reach for ESI instead when the personalised content genuinely must be in the initial HTML: it is relevant to search engines, it must work without JavaScript, or a moment of generic content before the fragment loads is unacceptable (for example, pricing that differs by customer group).

The full rule of thumb:

  • Whole page identical for everyone - public cache, nothing else needed.
  • Mostly identical, small personalised fragments - public cache plus AJAX by default, ESI where the fragment must be server-rendered.
  • Genuinely personalised throughout (dashboards, account areas) - private cache.
  • Writes data or must always be fresh - no-cache.

Controlling the cache from PHP

For anything beyond simple TTL-based caching, move the cache decision into your application using LiteSpeed's response headers. The key header is X-LiteSpeed-Cache-Control, which accepts the same kinds of values as the standard Cache-Control header but is read only by LiteSpeed. If both are present, LiteSpeed uses the X-LiteSpeed- version and ignores the standard one, so you can keep your standard Cache-Control header tuned for browsers and CDNs independently.

A robust integration is fail-safe by default: every response sends no-cache unless the application explicitly marks the page as cacheable.

// Default: do not cache anything
header('X-LiteSpeed-Cache-Control: no-cache');

// A page the application has decided is publicly cacheable
header('X-LiteSpeed-Cache-Control: public,max-age=300');

// A personalised page, cached privately per visitor
header('X-LiteSpeed-Cache-Control: private,max-age=300');
header('X-LiteSpeed-Vary: cookie=session_token');

Like all headers, these must be sent before any output. If your application uses output buffering via ob_start(), you have until the buffer is flushed.

Cache tags: grouping related pages

Cache tags let you label cached pages so you can purge them together later. Your application assigns tags when it serves a page:

header('X-LiteSpeed-Tag: post-42,category-news');

A page can carry multiple tags. When a post is updated, you purge its tag and every cached page that included it - the post page, any listing pages, the homepage - in a single operation. This is the most powerful approach for content-driven applications.

A few formatting rules apply:

  • Tags must contain only ASCII characters, with no spaces, commas or quotes.
  • The public: prefix is reserved. If a response is cached privately but carries tags that refer to public content, those tags must be prefixed, for example X-LiteSpeed-Tag: public:post-42,my-private-tag. If both the cache control and the tags are public, the prefix is optional.

Cache purging: where the real development work begins

Setting up caching is relatively straightforward. Purging stale cache entries the moment your data changes is the harder part, and it requires code inside your application. The payoff is significant: once purging is reliable, you no longer depend on short TTLs for freshness, and cached pages can live for hours or days off a single backend hit.

LiteSpeed supports purging by URL or by tag, both via the X-LiteSpeed-Purge response header.

Purge by URL

URL purges require the url= prefix and match exactly one URL - there is no wildcard or prefix matching:

header('X-LiteSpeed-Purge: url=/blog/post-1');

The url= prefix is required. A bare value such as X-LiteSpeed-Purge: /blog/post-1 is interpreted as a purge of a tag named /blog/post-1, not the URL, and will silently do nothing useful. Because URL purges are exact-match only, tag-based purging is almost always the better tool.

Purge by tag

To purge everything tagged post-42:

header('X-LiteSpeed-Purge: tag=post-42');

Multiple tags can be purged in one header:

header('X-LiteSpeed-Purge: tag=post-42,tag=category-news');

Purge everything

header('X-LiteSpeed-Purge: *');

This empties the public cache for your site. It is a blunt instrument - useful after a deployment or bulk import, but if you find yourself purging everything on every save, your tags are not granular enough.

Where to trigger purges

Purge headers must be sent in an HTTP response, which means your application needs a request that carries the purge. Common patterns include:

  • Sending purge headers in the response to an admin save or publish action.
  • A lightweight internal endpoint that your application calls via curl after a write operation.
  • A post-save hook or observer in your application's event system.

LiteSpeed processes purge headers on the response that contains them. The response itself does not need to be a cached page - it just needs to reach LiteSpeed with the correct header. Purges should only ever be triggered by successful write operations, never by ordinary GET requests.

Verifying that caching is working

Once your rules or headers are in place, check the response headers to confirm pages are being served from cache. A cache hit includes X-LiteSpeed-Cache: hit (or hit,private), while miss means the page was generated this time but a cache object has been created for next time. Our article on verifying LiteSpeed Cache is working walks through exactly how to do this.

Building a full integration with an AI coding tool

If you are building a custom PHP application and want a complete LSCache integration - tagging responses, purging on save, and handling edge cases - an AI coding tool such as GitHub Copilot, Cursor, Claude or ChatGPT can generate a solid working starting point. The key is giving it enough context.

For a task like this, consider using an AI tool that has direct access to your codebase and version control - for example GitHub Copilot in your IDE, or Cursor or Claude Code with your repository open. A full LSCache integration touches multiple files and needs to understand your application's structure, routing, and data model. Trying to describe all of that in a chat window is possible, but an AI that can read your 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 am building a custom PHP application hosted on LiteSpeed Web Server
(Enterprise edition, on a cPanel hosting account). I want to integrate
LiteSpeed Cache (LSCache) natively, without a plugin, controlling the
cache entirely through LiteSpeed's response headers.

My setup:
- Authentication: [describe how logged-in users are identified, e.g. a
  PHP session cookie named "session_token"]
- Paths that must never be cached: [e.g. /admin, /login, /register,
  /cart, /checkout]
- Default public cache TTL: [e.g. 86400 seconds. With tag-based purging
  handling freshness, the TTL is a safety net rather than the
  invalidation mechanism, so it can be long]
- Content types and how they relate: [e.g. posts belong to categories;
  saving a post should invalidate the post page, its category pages,
  and the homepage]
- Personalised fragments on otherwise-public pages: [e.g. the header
  shows the logged-in user's name and a basket count; handle these via
  a JavaScript call to a no-cache JSON endpoint / via ESI / not
  applicable]

Please generate a PHP cache helper class that:

1. Is fail-safe by default: every response sends
   "X-LiteSpeed-Cache-Control: no-cache" unless the application
   explicitly marks the page as cacheable.
2. Marks anonymous page responses as publicly cacheable with
   "X-LiteSpeed-Cache-Control: public,max-age=[TTL]".
3. Marks authenticated responses as privately cacheable with
   "X-LiteSpeed-Cache-Control: private,max-age=[TTL]" and sends
   "X-LiteSpeed-Vary: cookie=[cookie name]" so the private cache key
   is built from my session cookie.
4. Forces no-cache for POST requests, form handlers, and the excluded
   paths above.
5. Assigns cache tags via the "X-LiteSpeed-Tag" header based on the
   content being rendered (e.g. post-{id}, category-{slug}). Tags must
   be ASCII with no spaces, commas, or quotes. If a response is
   privately cached but a tag refers to public content, prefix that
   tag with "public:".
6. Provides purge methods using the "X-LiteSpeed-Purge" header:
   - by tag: "X-LiteSpeed-Purge: tag=post-42" (multiple tags comma
     separated)
   - by exact URL: "X-LiteSpeed-Purge: url=/path/to/page" (the "url="
     prefix is required; URL purges are exact-match only, no wildcards)
   - purge everything: "X-LiteSpeed-Purge: *"
7. Ensures all headers are sent exactly once, before any output, and
   that purge headers are only emitted after a successful write
   operation, never on ordinary GET requests.

Please also include:
- A brief explanation of where to call each method in the application
  lifecycle (bootstrap, render, post-save).
- A bias towards public caching: pages should only fall back to private
  cache or no-cache when they cannot be made public, with personalised
  fragments handled as described in my setup above.
- Notes on edge cases I should handle: output buffering, cache vary for
  mobile vs desktop, query strings, and cache warm-up.
- The header syntax above is taken from LiteSpeed's LSCache developer
  guide (https://docs.litespeedtech.com/lscache/devguide/controls/).
  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 framework or architecture - for example, a custom MVC framework, a legacy procedural codebase, or a microframework like Slim.
  • How authentication works - session cookie name, token-based auth, or a combination.
  • Your content model - what types of content exist, how they relate, and what changes when a piece of content is saved.
  • Any existing output buffering - if your application uses ob_start(), the AI needs to know where headers can safely be sent.
  • How you want personalised fragments handled - ESI keeps personalised content server-rendered inside publicly cached pages; the AJAX approach keeps the cache logic simpler. See the section above on punching holes in public pages, and tell the AI which you have chosen.

Reviewing the output

AI-generated code is a starting point, not a finished product. Before deploying, check the following:

  • Purge headers use the documented syntax: tag= for tags, url= for exact URLs, never bare paths.
  • Purge calls are triggered after a successful write, not before, and never on GET requests that do not modify data.
  • Private responses send X-LiteSpeed-Vary: cookie=... for your session cookie if it is not one of LiteSpeed's auto-detected names.
  • Cache tags are granular enough to avoid over-purging (purging the entire cache on every save defeats the purpose).
  • The no-cache logic covers all paths that handle form submissions or user-specific data.
  • If you have used ESI, the outer page enables esi=on and each fragment endpoint sends its own cache headers. If you have used the AJAX approach, the JSON endpoint is explicitly no-cache.
  • You have tested with the response header verification method to confirm pages are actually being cached and purged as expected.

Quick reference

Goal Rewrite rule Response header
Cache publicly with a TTL [E=cache-control:max-age=N] X-LiteSpeed-Cache-Control: public,max-age=N
Cache privately [E=cache-control:private,max-age=N] X-LiteSpeed-Cache-Control: private,max-age=N
Never cache [E=cache-control:no-cache] X-LiteSpeed-Cache-Control: no-cache
Vary by mobile/desktop [E=cache-control:vary=ismobile] X-LiteSpeed-Vary: value=ismobile
Vary on a cookie [E=Cache-Vary:cookie_name] X-LiteSpeed-Vary: cookie=cookie_name
Enable ESI processing [E=esi_on:1] X-LiteSpeed-Cache-Control: public,max-age=N,esi=on
Assign cache tags [E="cache-tag:tag1,tag2"] X-LiteSpeed-Tag: tag1,tag2
Purge by tag n/a X-LiteSpeed-Purge: tag=tag1
Purge by exact URL n/a X-LiteSpeed-Purge: url=/path
Purge everything n/a X-LiteSpeed-Purge: *

For the full list of supported directives and values, see the LSCache developer guide basic controls reference.

Further reading

If you get stuck integrating LSCache into your application, raise a support ticket and our team can help you review your configuration.

Was this helpful?
Your feedback helps us find gaps in the docs.
Still need a hand?
Real people, around the clock - start a chat or open a ticket and we'll help you put it right.