Hi @Ash G:
I didn\'t follow 100% where you were specifically having problems so I\'m not sure I really can answer your issues point-by-point but I can explain how to do this from ground up. And unless what you are doing is a good bit more involved then you mentioned it\'s a little bit more work than I think you were anticipating but it is still completely doable. And even if I\'m covering lots of ground that you already know there\'s a good chance other\'s with less knowledge or experience will find this via Google and be helped by it too.
Bootstrap WordPress with /wp-load.php
The first thing we need to do in your my_theme_css.php
file is bootstrap WordPress\' core library functions. The following line of code loads /wp-load.php
. It uses $_SERVER[\'DOCUMENT_ROOT\']
to locate the website\'s root so you don\'t have to worry about where you store this file on your server; assuming DOCUMENT_ROOT
is set correctly as it always should be for WordPress then this will bootstrap WordPress:
<?php
include_once("{$_SERVER[\'DOCUMENT_ROOT\']}/wp-load.php");
So that\'s the easy part. Next comes the tricky part...
PHP Scripts Must Handle All Caching Logic
Here\'s where I\'ll bet you might have stumbled because I sure did as I was trying to figure out how to answer your question. We are so used to the caching details being handled by the Apache web server that we forget or even don\'t realize we have to do all the heavy lifting ourselves when we load CSS or JS with PHP.
Sure the expiry header may be all we need when we have a proxy in the middle but if the request makes it to the web server and the PHP script just willy-nilly returns content and the "Ok" status code well in essence you\'ll have no caching.
Returning "200 Ok" or "304 Not Modified"
More specifically our PHP file that returns CSS needs to respond correctly to the request headers sent by the browser. Our PHP script needs to return the proper status code based upon what those headers contain. If the content needs to be served because it\'s a first time request or because the content has expired the PHP script should generate all the CSS content and return with a "200 Ok".
On the other hand if we determine based on the cache-related request headers that the client browser already has the latest CSS we should not return any CSS and instead return with a "304 Not Modified". The too lines of code for this are respectively (of course you\'d never use them both one line after another, I\'m only showing that way here for convenience):
<?php
header(\'HTTP/1.1 200 Ok\');
header(\'HTTP/1.1 304 Not Modified\');
Four Flavors of HTTP Caching
Next we need to look at the different ways HTTP can handle caching. The first is what you mention; Expires
:
- Expires: This
Expires
header expectz a date in the (PHP gmdate()
function) format of \'D, d M Y H:i:s\'
with a \' GMT\'
appended (GMT stands for Greenwich Mean Time.) Theoretically if this header is served the browser and downstream proxies will cache until the specified time passes after which it will start requesting the page again. This is probably the best known of caching headers but evidently not the preferred one to use; Cache-Control being the better one. Interestingly in my testing on localhost
with Safari 5.0 on Mac OS X I was never able to get the browser to respect the Expires
header; it always requested the file again (if someone can explain this I\'d be grateful.) Here\'s the example you gave from above:
header("Expires: Thu, 31 Dec 2020 20:00:00 GMT");
- Cache-Control: The
Cache-Control
header is easier to work with than the Expires
header because you only need to specify the number of time in seconds as max-age
meaning you don\'t have to come up with an exact date format in string form that is easy to get wrong. Additionally Cache-Control
allows several other options such as being able to tell the client to always re-validated the cache using the mustrevalidate
option and public
when you want to force caching for normally non-cachable requests (i.e requests via HTTPS
) and even not cache if that\'s what you need (i.e. you might want to force a a 1x1 pixel ad tracking .GIF not to be cached.) Like Expires
I was also unable to get this to work in testing (any help?) The following example caches for a 24 hour period (60 seconds by 60 minutes by 24 hours):
header("Cache-Control: max-age=".60*60*24.", public, must-revalidate");
- Last-Modified / If-Modified-Since: Then there is the
Last-Modified
response header and If-Modified-Since
request header pair. These also use the same GMT date format that the Expires
header use but they do a handshake between client and server. The PHP script needs to send a Last-Modified
header (which, by the way, you should update only when your user last updates their custom CSS) after which the browser will continue to send the same value back as an If-Modified-Since
header and it\'s the PHP script\'s responsibility to compare the saved value with the one sent by the browser. Here is where the PHP script needs to make the decision between serving a 200 Ok
or a 304 Not Modified
. Here\'s an example of serving the Last-Modified
header using the current time (which is not what we want to do; see the later example for what we actually need):
header("Last-Modified: " . gmdate(\'D, d M Y H:i:s\', time()).\'GMT\');
And here is how you\'d read the Last-Modified
returned by the browser via the If-Modified-Since
header:
$last_modified_to_compare = $_SERVER[\'HTTP_IF_MODIFIED_SINCE\'];
- ETag / If-None-Match: And lastly there\'s the
ETag
response header and the If-None-Match
request header pair. The ETag
which is really just a token that our PHP sets it to a unique value (typically based on the current date) and sends to the browser and the browser returns it. It the current value is different from what the browser returns your PHP script should regenerate the content an server 200 Ok
otherwise generate nothing and serve a 304 Not Modified
. Here\'s an example of setting an ETag
using the current time:
header("ETag: " . md5(gmdate(\'D, d M Y H:i:s\', time()).\'GMT\'));
And here is how you\'d read the ETag
returned by the browser via the If-None-Match
header:
$etag_to_match = $_SERVER[\'HTTP_IF_NONE_MATCH\'];
Now that we\'ve covered all that let\'s look at the actual code we need:
Serving the CSS file via init
and wp_enqueue_style()
You didn\'t show this but I figured I would show it for the benefit of others. Here\'s the function call that tells WordPress to use my_theme_css.php
for it\'s CSS. This can be stored in the theme\'s functions.php
file or even in a plugin if desired:
<?php
add_action(\'init\',\'add_php_powered_css\');
function add_php_powered_css() {
if (!is_admin()) {
$version = get_theme_mod(\'my_custom_css_version\',"1.00");
$ss_dir = get_stylesheet_directory_uri();
wp_enqueue_style(\'php-powered-css\',
"{$ss_dir}/my_theme_css.php",array(),$version);
}
}
There are several points to note:
- Use of
is_admin()
to avoid loading the CSS while in the admin (unless you want that...),
- Use of
get_theme_mod()
to load the CSS with a default version of 1.00
(more on that in a bit),
- Use of
get_stylesheet_directory_uri()
to grab the correct directory for the current theme, even if the current theme is a child theme,
- Use of
wp_enqueue_style()
to queue the CSS to allow WordPress to load it at the proper time where \'php-powered-css\'
is an arbitrary name to reference as a dependency later (if needed), and the empty array()
means this CSS has no dependencies (although in real world it would often have one or more), and
- Use of
$version
; Probably the most important one, we are telling wp_enqueue_style()
to add a ?ver=1.00
parameter to the /my_theme_css.php
URL so that if the version changes the browser will view it as a completely different URL (Much more on that in a bit.)
Setting $version
and Last-Modified
when User Updates CSS
So here\'s the trick. Every time the user updates their CSS you want to serve the content and not wait until 2020
for everyone\'s browser cache to timeout, right? Here\'s a function that combined with my other code will accomplish that. Every time you store CSS updated by the user, use this function or functionality similar to what\'s contained within:
<?php
function set_my_custom_css($custom_css) {
$new_version = round(get_theme_mod(\'my_custom_css_version\',\'1.00\',2))+0.01;
set_theme_mod(\'my_custom_css_version\',$new_version);
set_theme_mod(\'my_custom_css_last_modified\',gmdate(\'D, d M Y H:i:s\',time()).\' GMT\');
set_theme_mod(\'my_custom_css\',$custom_css);
}
The set_my_custom_css()
function automatically increments the current version by 0.01 (which was just an arbitrary increment value I picked) and it also sets the last modified date to right now and finally stores the new custom CSS. To call this function it might be as simple as this (where new_custom_css
would likely get assigned via a user submitted $_POST
instead of by hardcoding as you see here):
<?php
$new_custom_css = \'body {background-color:orange;}\';
set_my_custom_css($new_custom_css);
Which brings us to the final albeit significant step:
Generating the CSS from the PHP Script
Finally we get to see the meat, the actual my_theme_css.php
file. At a high level it tests both the If-Modifed-Since
against the saved Last-Modified
value and the If-None-Match
against the ETag
which was derived from the saved Last-Modified
value and if neither have changed just sets the header to 304 Not Modifed
and branches to the end.
If however either of those have changed it generates the Expires
, Cache-Control
. Last-Modified
and Etag
headers as well as a 200 Ok
and indicating that the content type is text/css
. We probably don\'t need all those but given how finicky caching can be with different browsers and proxies I figure it doesn\'t hurt to cover all bases. (And anyone with more experience with HTTP caching and WordPress please do chime in if I got any nuances wrong.)
There are a few more details in the following code but I think you can probably work them out on your own:
<?php
$s = $_SERVER;
include_once("{$s[\'DOCUMENT_ROOT\']}/wp-load.php");
$max_age = 60*60*24; // 24 hours
$now = gmdate(\'D, d M Y H:i:s\', time()).\'GMT\';
$last_modified = get_theme_mod(\'my_custom_css_last_modified\',$now);
$etag = md5($last_modified);
if (strtotime($s[\'HTTP_IF_MODIFIED_SINCE\']) >= strtotime($last_modified) || $s[\'HTTP_IF_NONE_MATCH\']==$etag) {
header(\'HTTP/1.1 304 Not Modified\');
} else {
header(\'HTTP/1.1 200 Ok\');
header("Expires: " . gmdate(\'D, d M Y H:i:s\', time()+$max_age.\'GMT\'));
header("Cache-Control: max-age={$mag_age}, public, must-revalidate");
header("Last-Modified: {$last_modified}");
header("ETag: {$etag}");
header(\'Content-type: text/css\');
echo_default_css();
echo_custom_css();
}
exit;
function echo_custom_css() {
$custom_css = get_theme_mod(\'my_custom_css\');
if (!empty($custom_css))
echo "\\n{$custom_css}";
}
function echo_default_css() {
$default_css =<<<CSS
body {background-color:yellow;}
CSS;
echo $default_css;
}
So with these three major bits of code; 1.) the add_php_powered_css()
function called by the init
hook, 2.) the set_my_custom_css()
function called by whatever code allows the user to update their custom CSS, and lastly 3.) the my_theme_css.php
you should pretty much have this licked.
Further Reading
Aside from those already linked I came across a few other articles that I thought were really useful on the subject so I figured I should link them here:
Epilogue:
But I would be remiss to leave the topic without making a closing comments.
Expires in 2020
? Probably Too Extreme.
First, I don\'t really think you want to set Expires
to the year 2020. Any browsers or proxies that respect Expires
won\'t re-request even after you\'ve made many CSS changes. Better to set something reasonable like 24 hours (like I did in my code) but even that will frustrate users for the day during which you make changes in the hardcoded CSS but forget to but the served version number. Moderation in all things?
This Might All Be Overkill Anyway!
As I was reading various articles to help me answer your question I came across the following from Mr. Cache Tutorial himself, Mark Nottingham:
The best way to make a script
cache-friendly (as well as perform
better) is to dump its content to a
plain file whenever it changes. The
Web server can then treat it like any
other Web page, generating and using
validators, which makes your life
easier. Remember to only write files
that have changed, so the
Last-Modified times are preserved.
While all this code I wrote it cool and was fun to write (yes, I actually admitted that), maybe it\'s better just to generate a static CSS file every time the user updates their custom CSS instead, and let Apache do all the heavy lifting like it was born to do? I\'m just sayin...
Hope this helps!