Pacific Blue Software Logo

htaccess Tips and Tricks

htaccess Tips and Tricks from a Working Site

Most .htaccess examples on the web show one rule in isolation. This article goes through the .htaccess that runs this site, a PHP site on shared Linux hosting with cPanel, and explains each part: what it does, why it is there and what order it has to go in. Each section stands on its own, so take only the parts you need.

It also covers two things that sit beside .htaccess: small PHP pages that do a redirect better than a rule would, and the PHP that turns clean URLs back into parameters and keeps each page at a single address.

Some topics have an article of their own and are only touched on here: www and https redirects, 410 and 403 status codes, custom error pages and password-protected folders.


The Order of the File

Apache reads rewrite rules from the top and, with the L flag, stops at the first one that applies. So the file is laid out with the most specific rules first:

  1. Redirects to the right host and scheme (www, https, other domains)
  2. Clean URLs that carry a parameter
  3. Shortcuts
  4. Adding .php to extensionless URLs
  5. Removing a trailing slash
  6. Headers, caching and compression, which are not rewrite rules and can go anywhere

A redirect placed after the .php rule would never see the original address, and a clean-URL rule placed after it would be rewritten twice.


Options -MultiViews

Options -MultiViews
RewriteEngine On

MultiViews is Apache's content negotiation. With it on, a request for /download-file/ping-log can be answered by download-file.php before any rewrite rule runs, and the rule that should have passed ping-log as a parameter never fires. Many hosts turn it on by default. If you rewrite URLs yourself, turn it off.


Send a Second Domain to the Main One

RewriteCond %{HTTP_HOST} ^(www\.)?example\.nz$ [NC]
RewriteRule ^(.*)$ https://example.com/$1 [R=302,L]

If you own other domains that point at the same hosting, send every page on them to the same page on the main domain rather than letting them show the site under a second name. Use R=301 if the second domain will only ever be an alias. A 302 keeps search engines from treating the move as permanent, which suits a domain you may later use for something else.


Clean URLs That Carry a Parameter

Each download on this site has its own page. Its address is /download-file/ping-log, but the script behind it is download-file.php?name=ping-log. One rule does that for three scripts:

RewriteCond %{HTTP_HOST} ^example\.com [NC]
RewriteRule ^(download-file|download-code|download-it)/([^/]+)$ $1.php?name=$2 [END]

The visitor and the search engines only ever see the clean address. How the PHP side reads the parameter and keeps the address tidy is covered further down.


Short Addresses for Deep Pages

RewriteCond %{QUERY_STRING} ^$
RewriteRule ^whatsmyip$ /developer-tools/internet/whats-my-ip.php? [L]

A page that people type in by hand, or that you quote on the phone, deserves a short address. This is a rewrite, not a redirect: there is no R flag, so the address bar keeps showing /whatsmyip while Apache serves the deeper page. The condition only applies it when there is no query string, and the ? at the end of the target makes sure none is added.

Because both addresses now show the same page, the deep page should give the full address as its canonical URL so search engines index only one.


URLs Without .php

RewriteCond %{DOCUMENT_ROOT}/$1.php -f
RewriteRule ^(?!.+\.\w{2,4})(.+)$ $1.php [L]
RewriteCond %{DOCUMENT_ROOT}/$1.html -f
RewriteRule ^(?!.+\.\w{2,4})(.+)$ $1.html [L]

Every page on this site is a .php file, but no address shows it. A request for /contact-us is served by contact-us.php, and the same for .html pages.

The site's own links are built without the extension, so the .php version of an address is never seen. Moving a page from .html to .php, or the other way, no longer changes its address.


Remove a Trailing Slash

RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} /(.*)/$
RewriteRule ^ /%1 [R=301,L]

/contact-us/ and /contact-us are two addresses to a search engine. This sends the slashed one to the other. The !-d condition leaves real folders alone, because /articles/ is a folder with an index page and needs its slash.


Pass Settings to PHP

SetEnv My_Server example.com

SetEnv puts a value in the environment of every script under that folder. PHP reads it with getenv ('My_Server') or $_SERVER ['My_Server']. It is a simple way to give the live site and a local copy different settings from the same PHP code, because each has its own .htaccess.

Two things to know. After a rewrite, Apache may also pass the value as REDIRECT_My_Server, so check both if one is empty. And anything in .htaccess can be read by anyone with access to the hosting account, so it is no place for passwords.


Security Headers

These cost nothing and are checked by every security scanner. Set them once in the root .htaccess and every page gets them.

Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=()"

Use set, not append, so a header is never sent twice. The old X-XSS-Protection header is ignored by current browsers and can be left out.

Content-Security-Policy

A Content-Security-Policy lists where scripts, styles, images and fonts may come from, and is the strongest defence against injected script. A long one can be split over lines with a backslash:

Header always set Content-Security-Policy "upgrade-insecure-requests; \
  default-src 'self'; \
  img-src https: data:; \
  object-src 'none'; \
  script-src 'self' https://www.googletagmanager.com; \
  frame-ancestors 'self'; base-uri 'self'"

If you allow inline scripts with a nonce, the nonce must be a new random value for every page view. That means PHP has to generate it and send the header itself; a fixed nonce written into .htaccess gives no protection. Start with Content-Security-Policy-Report-Only and watch the browser console until nothing is being blocked that should not be.

A folder's own .htaccess can override a header for just that folder, which is handy for a test area that needs a looser policy.


Caching by File Type

<FilesMatch "\.(css|html|shtml|php|xml)$">
  Header set Cache-Control "max-age=3600, must-revalidate"
</FilesMatch>
<FilesMatch "\.js$">
  Header set Cache-Control "max-age=172800, must-revalidate"
</FilesMatch>
<FilesMatch "\.(ico|jpe?g|png|gif|svg|mp4)$">
  Header set Cache-Control "max-age=2592000, must-revalidate"
</FilesMatch>
<FilesMatch "\.(rar|zip|txt)$">
  Header set Cache-Control "no-cache, no-store, must-revalidate"
</FilesMatch>

Pages for an hour, scripts for two days, images for a month, and downloads never, so a new version is always fetched. Note the \.: without the backslash the dot matches any character.

The catch with long cache times is that a changed stylesheet is not seen until the cache expires. Add a version to the link, as in main.css?017, and change it when the file changes. The browser treats it as a new file.


Compression

<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/css text/plain text/xml
  AddOutputFilterByType DEFLATE text/javascript application/javascript
  AddOutputFilterByType DEFLATE application/xml application/rss+xml image/svg+xml
  AddOutputFilterByType DEFLATE font/ttf font/otf image/x-icon
</IfModule>

Text compresses to a fraction of its size. Leave out JPEG, PNG, ZIP and RAR, which are already compressed and only cost CPU time. Many examples online still carry BrowserMatch lines for Netscape 4 and old Internet Explorer; they can go. Many hosts also compress at the server level already, which you can check by looking for Content-Encoding: gzip in the response headers.


Small Files in Subfolders

Not everything belongs in the root file. A one-line .htaccess in each folder of scripts, images or downloads stops Apache listing the folder's contents:

Options -Indexes

And a canonical header on a PDF that is also an HTML article tells search engines to index the article, not the PDF:

<Files "project-history-list.pdf">
  Header set Link "<https://example.com/articles/project-history-list>; rel=\"canonical\""
</Files>

Sometimes a Redirect Page Beats a Rule

Over the years pages get renamed: /contact becomes /contact-us, /credits becomes /attributions. The usual advice is a redirect rule in .htaccess for each one. This site instead keeps a tiny PHP file under the old name:

<?php
header ('Location: https://example.com/contact-us', true, 301);
exit;

Saved as contact.php, and thanks to the extensionless rule above, a request for /contact runs it and gets a permanent redirect. For an old address that is rarely visited, this is the better choice:

Keep rules for patterns that cover many addresses at once, such as a whole folder or the www redirect, and for anything requested often.

To avoid typing the domain into every page, build it from the request:

<?php
$Scheme = isset ($_SERVER ['HTTPS']) ? 'https' : 'http';
header ("Location: $Scheme://{$_SERVER ['HTTP_HOST']}/contact-us", true, 301);
exit;

The same page then works on a local copy of the site as well. Send the status with header ()'s third argument rather than a separate HTTP/1.1 301 line, and avoid a meta refresh page, which search engines do not treat as a true redirect.


The PHP Side of Clean URLs

The rewrite rule turns /download-file/ping-log into download-file.php?name=ping-log. The script then has three jobs: read the name, make sure there is only one address for each page, and say so properly when the name is wrong.

Read the parameter, with a fallback

function Get_Param ($Name)
{$Value = $_GET [$Name] ?? '';
 if ($Value === '')
    {$Path  = parse_url ($_SERVER ['REQUEST_URI'], PHP_URL_PATH);
     $Value = basename ($Path);
    }
 return preg_match ('/^[a-z0-9_-]+$/i', $Value) ? $Value : '';
}

If the rewrite did not run, on a local server without the rule for instance, the name is taken from the last part of the address instead. Either way only letters, digits, dashes and underscores are accepted, so nothing odd ever reaches a file path or a database query.

One address per page

The site's names use dashes, but people and old links use underscores. Rather than serve the same page at both, the script redirects permanently to the dashed form:

$Name = Get_Param ('name');
$Dashed = str_replace ('_', '-', $Name);
if ($Name !== $Dashed)
   {header ('Location: /download-file/'.$Dashed, true, 301);
    exit;
   }

Query strings are the other source of duplicates: /page?utm_source=x and /page are different addresses. Every page here writes a canonical link with the query string removed, so whatever was added to the link, search engines index the plain address:

$Can_Url = strtok ($Current_Url, '?');
echo "<link rel='canonical' href='$Can_Url'>";

A real 404 for an unknown name

When the name does not match a download, the page still shows a friendly message, but sends a 404 status first:

if (!$Found)
   {http_response_code (404);}

Without it, every made-up address returns 200 and a "not found" message. Search engines call that a soft 404, and may index any number of junk addresses as real pages.


Testing Changes Safely


Summary

Want Use
Rewrites to work predictably Options -MultiViews
Clean URL with a parameter RewriteRule ^(page)/([^/]+)$ $1.php?name=$2 [END]
No .php in addresses -f check, then rewrite to $1.php
One form of each address Trailing slash removed, canonical without query string, underscores redirected
Rarely used old address A small PHP redirect page, not a rule
Faster, safer pages Security headers, Cache-Control by type, compression for text

Useful References


What Each Rule in Our .htaccess Does, and Why


Back to Articles for Developers
Back to Website Articles
Next: Protect directories with XAMPP / Apache

If you found this useful, then please consider chipping in.
Chip In with PayPal
QR Code to chip in Please chip in if helpful