GZIP compression of static assets via mod_rewrite and PHP

This PHP script has changed since its initial release. You can find the latest version of the GZIP compression script on github.

Zur deutschen Version dieses Artikels

To conserve bandwidth and speed up page loading, it pays off to also compress static files like stylesheets and scripts in addition to the page itself with GZIP and set meaningful cache retention times. With Apache, this process can be set up with a custom .htaccess file as dynamic on-the-fly compression with either mod_deflate, or alernatively via PHP. In my case I settled with a PHP solution which stores the compressed data on disk, so repeated requests don’t cause the files to be compressed over and over again, but only on first access (or if the file has changed).

The PHP script which takes care of the compression of requested files and stores the compressed data as .gz files on disk looks like this:

PHP script

<?php

function get_content_type($file) {
    // Determine Content-Type based on file extension
    // Default to text/html
    $info = pathinfo($file);
    $content_types = array('css' => 'text/css; charset=UTF-8',
                           'html' => 'text/html; charset=UTF-8',
                           'gif' => 'image/gif',
                           'ico' => 'image/x-icon',
                           'jpg' => 'image/jpeg',
                           'jpeg' => 'image/jpeg',
                           'js' => 'application/javascript',
                           'json' => 'application/json',
                           'png' => 'image/png',
                           'txt' => 'text/plain',
                           'xml' => 'application/xml');
    if (empty($content_types[$info['extension']]))
        return 'text/html; charset=UTF-8';
    return $content_types[$info['extension']];
}

function main() {
    // Get file path by stripping query parameters from the request URI
    if (!empty($_SERVER['REQUEST_URI']))
        $path = preg_replace('/\/?(?:\?.*)?$/', '', $_SERVER['REQUEST_URI']);

    // If the path is empty, either use DEFAULT_FILENAME if defined, or exit
    if (empty($path)) {
        if (defined('DEFAULT_FILENAME')) $path = '/' . DEFAULT_FILENAME;
        else die();
    }

    $file = dirname(__FILE__) . $path;
    if (!file_exists($file)) die();

    $mtime = filemtime($file);

    // If the user agent sent a IF_MODIFIED_SINCE header, check if the file
    // has been modified. If it hasn't, send '304 Not Modified' header & exit
    if (!empty($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
        $mtime <= strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
        header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified', true, 304);
        exit;
    }
    
    // Determine Content-Type based on file extension
    $content_type = get_content_type($file);

    // If the user agent accepts GZIP encoding, store a compressed version of
    // the file (<filename>.gz)
    if (!empty($_SERVER['HTTP_ACCEPT_ENCODING']) &&
        in_array('gzip', preg_split('/\s*,\s*/',
                                    $_SERVER['HTTP_ACCEPT_ENCODING']))) {
        // Only write the compressed version if it does not yet exist or the
        // original file has changed
        $gzfile = $file . '.gz';
        if (!file_exists($gzfile) || filemtime($gzfile) < $mtime)
            file_put_contents($gzfile, gzencode(file_get_contents($file)));
        // Send compression headers and use the .gz file instead of the
        // original filename
        header('Content-Encoding: gzip');
        $file = $file . '.gz';
    }

    // Vary max-age and expiration headers based on content type
    switch ($content_type) {
        case 'image/gif':
        case 'image/jpeg':
        case 'image/png':
            // Max-age for images: 31 days
            $maxage = 60 * 60 * 24 * 31;
            break;
        default:
            // Max-age for everything else: 7 days
            $maxage = 60 * 60 * 24 * 7;
    }

    // Send remaining headers
    header('Vary: Accept-Encoding');
    header('Cache-Control: max-age=' . $maxage);
    header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $maxage) . ' GMT');
    header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
    header('Content-Type: ' . $content_type);
    header('Content-Length: ' . filesize($file));
    
    // If the request method isn't HEAD, send the file contents
    if ($_SERVER['REQUEST_METHOD'] != 'HEAD') readfile($file);
}

main();

?>

The script is stored as ‚gz.php‘ inside of the root directory of my WordPress installation. It detects if files have changed and recompresses them if needed. In addition it sends a ‚304 Not Modified‘ header if a file hasn’t changed since it was last requested. Furthermore we need to adjust the  .htaccess file. For my own WordPress installation it looks like this:

.htaccess

# Set 'Vary' response header for .gz files
<FilesMatch "\.gz$">
<IfModule mod_headers.c>
Header always append Vary "Accept-Encoding"
</IfModule>
</FilesMatch>

<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType application/json "access plus 1 week"
ExpiresByType application/x-javascript "access plus 1 week"
ExpiresByType application/xml "access plus 1 week"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/x-icon "access plus 1 month"
ExpiresByType text/css "access plus 1 week"
ExpiresByType text/html "access plus 1 week"
ExpiresByType text/plain "access plus 1 week"
</IfModule>

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

# BEGIN GZIP
<IfModule mod_rewrite.c>
# If the user agent accepts gzip encoding...
RewriteCond %{HTTP:Accept-Encoding} gzip
# ...and the requested file exists...
RewriteCond %{REQUEST_FILENAME} -f
# ...then use a PHP script serve a compressed version. Done.
RewriteRule \.(css|html|ico|js|json|txt|xml)$ /gz.php [L]
</IfModule>
# END GZIP

For .gz file access, a ‚Vary‘ header is sent to enable correct caching over proxies. Following this directive are several directives for cache retention times (‚Expires‘ and ‚Cache Control‘ header, users may change those as it suits them. In my case, CSS and JS files expire after one week and images after one month.

Alternatively, the mod_rewrite rule for the PHP script can be changed so only the first request calls upon the script, and subsequently the .gz files are accessed directly (which has the drawback that you need to remember to delete any .gz files manually if you make changes, but doesn’t need to call PHP for each file access):

<IfModule mod_rewrite.c>
# If the user agent accepts gzip encoding...
RewriteCond %{HTTP:Accept-Encoding} gzip
# ...and if gzip-encoded version of the requested file exists (<file>.gz)...
RewriteCond %{REQUEST_FILENAME}.gz -f
# ...then serve the gzip-encoded file. Done.
RewriteRule ^(.+)$ $1.gz [L]
# Or if the user agent accepts gzip encoding...
RewriteCond %{HTTP:Accept-Encoding} gzip
# ...and the requested file exists...
RewriteCond %{REQUEST_FILENAME} -f
# ...then use a PHP script serve a compressed version. Done.
RewriteRule \.(css|html|ico|js|json|txt|xml)$ /gz.php [L]
</IfModule>

Credits: .gz-Icon based on generic package icon from GNOME 2.18 Icon Theme, released under GNU GPL 2.0 Lizenz

Creative Commons Lizenzvertrag This article was published under Attribution-NonCommercial-ShareAlike 3.0 Unported (CC BY-NC-SA 3.0).

2 Antworten auf “GZIP compression of static assets via mod_rewrite and PHP”

  • So first off – great article.

    I have been interested in ways to gzip using PHP for some days and have yet to find but this page that describes the matter. I like the ease of the script, cause I´m new to PHP.

    So my issue with this script is that the stylesheets are broken for some reason. The script does what it advertises but my page does not load with any styles. No stylesheet seems to be loaded when viewing through a mobile browser but I get a full page if I view it through a VPN page like:

    https://www.freevpnbrowser.com/webproxy/index.php

    This is my temporary page for demonstration:

    http://www.keypleezer.com/tests/caching-with-php/index.php

    Hope you know what could be wrong. Do I have to change something in the script for normal HTML-based, static pages? I don´t currently run WP for this.

    • The stylesheets & scripts return 404s. You probably need to change configuration, i.e. adjust RewriteBase in your .htaccess and/or change the BASE in gz.config.inc.php