Real World Example

Until recently boldminded.com was not using any caching strategy. The amount of traffic it received didn't really necessiate it, however, I thought it would be a good idea to eat my own dog food. It only took a couple of hours of work to install Speedy and configure it for static caching. Admittedly most of the work was getting the CSS animation on the Login menu option to work how I wanted it to.

The next code block is taken directly from the Speedy docs. Basically it's saying if it finds a file at the requested path on the server it will render it instead of continuing to render ExpressionEngine's index.php bootstrap file, thus entirely bypassing ExpressionEngine.

RewriteCond %{REQUEST_URI} !^/system [NC]
RewriteCond %{QUERY_STRING} !ACT|URL [NC]
RewriteCond %{REQUEST_METHOD} !=POST [NC]
RewriteCond $1 !\.(css|js|gif|jpe?g|png) [NC]
RewriteCond %{DOCUMENT_ROOT}/static/default_site/static%{REQUEST_URI}/index\.php -f
RewriteRule ^ /static/default_site/static%{REQUEST_URI}/index\.php [L,QSA]

# normal index removal
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L]

As with any staticly or heavily cached site that has user specific content is not showing the wrong content to a user, or potentially showing user A user B's profile information. I took the easy path and I'm simply ignoring the pages that contain information that are very member specific, or contain a lot of member information in them. Below is the exact Speedy configuration on boldminded.com located in my config.php file. You can see that it is ignoring several sections or specific pages on the site. The first rule states it will ignore any of the support ticket specific pages, but will cache the top level /support page listing all of the tickets.

$config['speedy_enabled'] = 'yes';
$config['speedy_driver'] = 'static';
$config['speedy_static_enabled'] = 'yes';
$config['speedy_ttl'] = 2629800;
$config['speedy_static_settings'] = [
    'ignore_urls' => [
        ['url' => '^support/.*'],
        ['url' => '^account'],
        ['url' => '^store'],
        ['url' => '^claim'],
    ],
];

This is the first line of my global.group/_header.html file, which is included on every single page of the site. Unless you have Custom System Messages installed you can ignore the conditional and because all that is necessary is the the {exp:speedy:static} line. This tells Speedy when the template is loaded by ExpressionEngine to capture the output buffer (e.g. the entire final document rendered by PHP on the server) I'm using CSM to handle the ExpressionEngine message page, but most importantly this conditional tells EE not to cache any action page, such as logging in, logging out, the 404 page, and errors specific to the current user.

{if !csm:action}
    {exp:speedy:static}
{/if}

In this example csm:action is an early parsed global variable, which is why this conditional will work. If you're trying to conditionally wrap the static tag based on an entry variable, such as entry_date, it will execute the static tag and cache the page. You can get around this parse order issue by creating a new embed and placing the static tag inside of it:

<!-- pages.group/index.html -->
{if entry_date <= current_time}
    {embed="tags/static"}
{/if}

<!-- tags.group/static.html -->
{exp:speedy:static}

This is the navigation code from my global.group/_header.html file. Before adding Speedy the span.ajax-menu is where the contents of the next file resided. I simply moved it from the main header file and into it's own template file. When the statically cached page loads it will display the "Login" text until the JavaScript below is called and attempts to replace it. There is a small CSS animation that you may get a quick glimpse of each time the page loads before this replacement happens.

<ul class="nav nav_main {if segment_1 == 'add-ons'}nav_main-isActive{/if}">
    <li>
        <!-- logo code -->
    </li>
    <li {if segment_1 == "add-ons"}class="here"{/if}><a href="{site_url}add-ons/">Add-ons</a></li>
    <li {if segment_1 == "support"}class="here"{/if}><a href="{site_url}support/">Support</a></li>
    <li {if segment_1 == "news"}class="here"{/if}><a href="{site_url}news/">News</a></li>
    <li><a href="http://docs.boldminded.com">Docs</a></li>
    <span class="ajax-menu">
        Login
    </span>
</ul>

This is the content of a new template, global.group/ajax-menu.html. All of this used to be located where the span.ajax-menu tag is now.

{exp:store:cart}
{if no_items}{/if}
<li {if segment_1 == "store"}class="here"{/if}><a href="{site_url}store/cart">Cart ({order_qty})</a></li>
{/exp:store:cart}

<li class="accountTopNav {if segment_1 == 'account'}here{/if}">
    {if logged_in}
    <a href="#" class="js-toggleAccount">My Account</a>
    {if:else}
    <a href="{path='account/profile'}">Login</a>
    {/if}
    <ul class="js-accountLinks">
        {if logged_in}
        <li><span class="isHiddenMobile-inline">{screen_name}</span> (<a href="{path=LOGOUT}">Log out?</a>)</li>
        <li><a href="{path='account/profile'}">My Profile</a></li>
        <li><a href="{path='account/purchases'}">My Purchases</a></li>
        <li><a href="{path='account/licenses'}">My Licenses</a></li>
        {/if}
    </ul>
</li>

Finally, the JavaScript that is called on each page load. Since this is an Ajax request to a template that does not have any {exp:speedy:*} caching tags, it is always a live, member specific render of the template each page load. I like to borrow a term from Magento's caching mechanisms and refer to this as "hole punching." Magento actually does this server side without needing JavaScript, but doing so is very complicated. A similar implementation in ExpressionEngine would still require EE to fully boot up (as it does in Magento). At this point you would be better off using Speedy's fragment caching instead of full page static caching. It's still booting ExpressionEngine, but most of the template and add-on parsing code is not called because the results are theoretically cached. By using JavaScript Ajax requests you can replace contents of a statically cached page with live data specific to the current user's session. This still requires booting ExpressionEngine, but since it's an Ajax request it does not block the initial page load time. The user sees the main page loaded very quickly, then additional relevant content is updated as needed.

$(function () {
    $.ajax({
        url: '/global/ajax-menu'
    })
        .done(function(data) {
            $('.ajax-menu').replaceWith(data);
        });
});

Now that pages are cached, we need to clear the cache when the page is updated in ExpressionEngine's control panel. I currently have only 3 cache breaking rules. When an entry is updated is simply deletes the cache items in that channel that match the url_title of the entry being saved.

When an add-on entry is updated, the matching static file is also deleted:

Similarly, when a blog post is updated:

And last when any support ticket is created or updated:

CSRF tokens

One additional consideration I had to make is how to handle statically cached pages that contain forms to add an add-on to the cart. CSRF tokens are important to prevent session hi-jacking, and ExpressionEngine has them enabled by default. I could have been lazy and disabled CSRF tokens (don't do this). However, I took this into account when making Speedy. If you're using static caching in Speedy it will automatically update the static file on each request with a unique token per user. If you inspect one of the add-on pages you will see a <!-- CSRF_TOKEN_UPDATED --> html comment indicating that the token is unique to each user.

Keep in mind

In order for static caching to work, Speedy needs to create and maintain a few additional PHP utility files on the server which is actually responsible for serving up the static cache (even if it's static html cache or Redis based static pages). Any time your Speedy configuration changes you will be notified to regenerate these files. For example, if I were to add or update one of the cache breaking rules above, it will notify me to regenerate the utility files.

Last updated