WebGee DocsDocs
WordPress

Redirect mixed-case URLs to lowercase

Capitalised URLs get their own LiteSpeed cache entry and miss it on every visit. One Cloudflare redirect rule points every request at a single lowercase URL.

Make every request for your site resolve to one lowercase URL, so LiteSpeed serves a single cached copy of each page instead of one copy per capitalisation.

Applies to any site on shared, reseller or Premium hosting that sits behind Cloudflare, on any Cloudflare plan including Free. You need access to the Cloudflare account the domain uses — see Put your site behind Cloudflare.

Why capitalisation splits the cache

LiteSpeed Cache keys each entry on the request URI as a literal string, so these are two separate entries:

https://example.com/Blog/My-Great-Article
https://example.com/blog/my-great-article

Same page, same content, same canonical tag — two cache entries, each warmed on its own.

Capitalised requests arrive continuously, from:

  • category, tag and custom post type slugs created with a capital letter and never normalised,
  • old internal links written before the slugs were tidied,
  • backlinks and syndicated copies that kept whatever casing was in an og:url or in a pasted link,
  • Google's index, which keeps requesting the exact string it crawled. A canonical tag consolidates indexing; it does not stop the request arriving at the URL that was indexed.

The last one is the expensive one. Search traffic landing on a rarely-requested capitalised variant misses the cache, so PHP and MySQL rebuild the page on every visit, while the lowercase version sits cached and fast serving almost nobody. Core Web Vitals field data (CrUX) is collected from real visits, so the measured experience of that page becomes the slow, uncached path.

It is easy to miss, because:

  • the overall cache hit ratio stays healthy — a small set of permanently cold URLs disappears into the aggregate,
  • you type lowercase URLs when you test the site, and so does everyone else who works on it,
  • SEO crawlers normalise URLs before fetching them, so an audit will not reproduce it.

Apache and LiteSpeed do not lowercase URLs by themselves, and that is deliberate: paths are case-sensitive on Linux, and real files depend on it.

Check whether it affects you

Download a raw access log and list the successful requests for paths that contain a capital letter and are not files:

awk '$9 == 200 && $7 ~ /[A-Z]/ && $7 !~ /\./ {print $7}' example.com-ssl_log | sort | uniq -c | sort -rn | head -20

Every path with a meaningful count is a page that has been served from PHP rather than from cache on each of those visits.

In Google Search Console, the same problem shows up in Performance → Pages as one page listed twice under two different casings.

Add the redirect rule in Cloudflare

  1. In Cloudflare, select the domain, then open Rules → Overview.

    The Cloudflare Rules Overview page, filtered to Redirect Rules, with the Create rule button at the top right

  2. Select Create rule, then Redirect Rules.

  3. In Rule name, enter a name you will recognise later, such as UnifiedURLs.

  4. Under If incoming requests match…, select Custom filter expression.

  5. Paste this into the expression box. If the form shows the visual builder rather than a text box, select Edit expression in the corner to switch to text.

    (http.request.uri.path contains "A" or http.request.uri.path contains "B" or http.request.uri.path contains "C" or http.request.uri.path contains "D" or http.request.uri.path contains "E" or http.request.uri.path contains "F" or http.request.uri.path contains "G" or http.request.uri.path contains "H" or http.request.uri.path contains "I" or http.request.uri.path contains "J" or http.request.uri.path contains "K" or http.request.uri.path contains "L" or http.request.uri.path contains "M" or http.request.uri.path contains "N" or http.request.uri.path contains "O" or http.request.uri.path contains "P" or http.request.uri.path contains "Q" or http.request.uri.path contains "R" or http.request.uri.path contains "S" or http.request.uri.path contains "T" or http.request.uri.path contains "U" or http.request.uri.path contains "V" or http.request.uri.path contains "W" or http.request.uri.path contains "X" or http.request.uri.path contains "Y" or http.request.uri.path contains "Z") and http.request.uri.path.extension eq ""

    The obvious version of this is http.request.uri.path matches "[A-Z]", but the matches operator needs a Business or Enterprise plan. The chain of contains comparisons does the same job on every plan.

  6. Under Then…, set Type to Dynamic and paste this as the Expression:

    concat("https://", http.host, lower(http.request.uri.path))
  7. Set Status code to 301 - Permanent Redirect and tick Preserve query string.

  8. Under Place at, select First. The redirect should happen before any other rule does work on a URL that is about to change.

  9. Select Save.

The Edit Single Redirect form, showing the custom filter expression, the dynamic redirect expression, the 301 status code and the rule order

http.request.uri.path.extension eq "" is the half of the expression that keeps real files out of the redirect, and it matters. Uploads are full of names like IMG_2024.jpg and Banner-Photo.png, and the filesystem is case-sensitive — lowercasing those paths turns working images into 404s. Leave it in. The exception is a site whose permalinks end in .html: those pages are excluded along with the files.

Why 301 and not 302

A permanent redirect tells search engines to consolidate ranking signals onto the lowercase URL instead of indexing both. Browsers and downstream caches also store a 301, so a repeat visit to the capitalised URL is redirected without another round trip to Cloudflare.

Check it worked

The capitalised URL should now answer with a redirect:

curl -sI https://example.com/Blog/My-Great-Article | head -n 3

Look for HTTP/2 301 and location: https://example.com/blog/my-great-article.

Request the lowercase URL twice and check that it is cached:

curl -sI https://example.com/blog/my-great-article | grep -i litespeed

The second request should return x-litespeed-cache: hit — see Check whether LiteSpeed Cache is working.

Then confirm a file with capitals in its name still loads. This must return 200:

curl -so /dev/null -w '%{http_code}\n' https://example.com/wp-content/uploads/2026/01/IMG_2024.jpg

If it did not work

  • The capitalised URL still returns 200. The hostname is not proxied through Cloudflare — the DNS record needs to be orange-clouded, or rules never see the traffic. Check as well that the rule shows as Active.
  • Images have become 404s. The extension test is missing, or the brackets around the or chain were dropped when pasting. and binds more tightly than or, so without the brackets the exclusion applies only to the final contains "Z". Correct the expression, then purge the Cloudflare cache: the wrong redirect has been cached.
  • The site is in a redirect loop. Something is sending the lowercase URL back to a capitalised one — a plugin, a .htaccess rule, or a WordPress Site Address saved with a capital letter. Fix it at that end rather than removing this rule.
  • A URL still behaves the old way after you edit the rule. Browsers cache 301s aggressively. Test with curl or a private window rather than the tab you have been using.

Why this cannot go in .htaccess

The snippet that circulates for this problem uses a rewrite map:

RewriteMap lc int:tolower

RewriteMap is valid only in server config and virtual host context, in Apache and in LiteSpeed alike. In a .htaccess file Apache returns a 500 error, and on shared hosting you have no access to the level where the directive would be allowed. There is no .htaccess-only equivalent — mod_rewrite cannot change the case of a string without a map.

If the site is not behind Cloudflare, do it in PHP instead. Create wp-content/mu-plugins/ if it does not exist, and save this as wp-content/mu-plugins/lowercase-urls.php. Must-use plugins load automatically and need no activation:

<?php
/**
 * Plugin Name: Lowercase URL redirect
 */
add_action( 'init', function () {
	if ( is_admin() || wp_doing_ajax() || 'GET' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
		return;
	}

	$parts = explode( '?', $_SERVER['REQUEST_URI'] ?? '', 2 );
	$path  = $parts[0];

	if ( $path === strtolower( $path ) || strpos( $path, '/wp-json/' ) === 0 ) {
		return;
	}

	wp_safe_redirect( strtolower( $path ) . ( isset( $parts[1] ) ? '?' . $parts[1] : '' ), 301 );
	exit;
} );

This only runs for requests WordPress handles, so files in wp-content/uploads are never touched. It is a fallback rather than a replacement: the request still reaches PHP, which is the work the Cloudflare rule avoids entirely.

On this page