Supporting conditional HTTP requests in PHP (PHP)
RFC 7232 implements support for conditional requests in HTTP. These are commonly used by downstream caches (including those built into browsers) in order to revalidate content - i.e. check whether the copy in cache is stale or whether it should still be considered valid.
The primary mechanisms for this are If-Modified-Since (i.e. has it chanced since date x) and If-None-Match (i.e. does it's Etag match any of those). If the metadata validates, then a HTTP 304 (Not Modified) will be returned, otherwise the requested content will be returned using whatever status code is appropriate for the request (usually a 200, but it could equally be a 206 if the request specifies a range)
Because PHP is used in dynamic pages, conditional requests won't simply work out of the box. To support conditional requests, you need to generate some metadata for whatever asset is being requested and include that in your initial response to the client. You can then validate that if it's later included in a conditional request
The benefit of doing this, though, is that you can save quite a few CPU cycles where a revalidation is possible - particularly if there are any components of your page/asset which are expensive to generate
Details
- Language: PHP
- License: BSD-3-Clause
Snippet
/*** Check whether the request is conditional, if it is, then evaluate the headers and behave accordingly
*
*
* @arg mtime - Last-Modified for copy of the asset in the database/on disk
* @arg etag - Generated Etag for copy of the asset in the database/on disk
*
* @return mixed
*/
function evaluateConditionalRequest($mtime,$etag){
// Only honour conditionals for HEAD/GET
if ((stripos($_SERVER['REQUEST_METHOD'], 'HEAD') === FALSE) && stripos($_SERVER['REQUEST_METHOD'], 'GET') === FALSE) {
return false;
}
/* Process the strong indicator first - Etags
Technically an ETag can be a weak indicator if prefixed with w/ so that should be handled, but isn't
*/
if (isset($_SERVER['HTTP_IF_NONE_MATCH'])){
// There may be several etags, per the RFCs
$etag_candidates=explode(",",$_SERVER['HTTP_IF_NONE_MATCH']);
foreach ($etag_candidates as $cand){
// Remove any whitespace from the header
$cand=trim($cand);
if ("$cand" == "$etag"){
returnNotModified($etag);
}
}
}elseif(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])){
// Convert to epoch
$lastmod = strtotime($mtime);
// Validate the date is a HTTP date
if (validateDate($_SERVER['HTTP_IF_MODIFIED_SINCE'], 'D, d M Y H:i:s T')){
$candtime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ( $lastmod <= $candtime && $lastmod != 0 ){
returnNotModified(false,$mtime);
}
}
}
return true;
}
/** Set the status code to 304.
* Also resend Etag and Last-mod as Apache strips them out when we change the status
*
*
*/
function returnNotModified($etag,$lastmod=false){
header("HTTP/1.1 304 Not Modified",true,304);
// Only include an etag if it revalidated (otherwise Apache will strip the Last-Mod
if ($etag){
header("ETag: $etag");
}
if ($lastmod){
header("Last-Modified: $lastmod");
}
// Close the request
exit();
}
/* Check that a date is in a correct and valid format
* Shamelessly nabbed from http://php.net/manual/en/function.checkdate.php/#113205
*
*/
function validateDate($date, $format = 'Y-m-d H:i:s')
{
// Ensure the date is in GMT (RFC2616)
$date=rtrim($date);
$tz=substr($date,-3,3);
if ($tz != "GMT"){
return false;
}
$d = DateTime::createFromFormat($format, $date);
return $d && $d->format($format) == $date;
}
Usage Example
<?php
/** Example Usage - Generate a test page and allow revalidation of it
Test instructions
- Request the page: curl -v http://yoursite/path-to-script.php
- Pull the ETag and Last-Modified out of the headers
- Test revalidation with ETag (Should get a HTTP 304):
curl -v http://yoursite/path-to-script.php -H "If-None-Match: abcdefg"
- Test Revalidation with Last-Modified (Should get a HTTP 304)
curl -v http://yoursite/path-to-script.php -H -H "If-Modified-Since: Thu, 05 Apr 2018 06:47:00 GMT"
- Test with incorrect Etag (should be a HTTP 200)
curl -v http://yoursite/path-to-script.php -H "If-None-Match: 123456"
- Test with an older date (should be HTTP 200)
curl -v http://yoursite/path-to-script.php -H -H "If-Modified-Since: Wed, 05 Apr 2017 06:47:00 GMT"
- Test with a future date (should be HTTP 304)
curl -v http://yoursite/path-to-script.php -H -H "If-Modified-Since: Fri, 05 Apr 2019 06:47:00 GMT"
*/
/*** Check whether the request is conditional, if it is, then evaluate the headers and behave accordingly
*
*
* @arg mtime - Last-Modified for copy of the asset in the database/on disk
* @arg etag - Generated Etag for copy of the asset in the database/on disk
*
* @return mixed
*/
function evaluateConditionalRequest($mtime,$etag){
// Only honour conditionals for HEAD/GET
if ((stripos($_SERVER['REQUEST_METHOD'], 'HEAD') === FALSE) && stripos($_SERVER['REQUEST_METHOD'], 'GET') === FALSE) {
return false;
}
/* Process the strong indicator first - Etags
Technically an ETag can be a weak indicator if prefixed with w/ so that should be handled, but isn't
*/
if (isset($_SERVER['HTTP_IF_NONE_MATCH'])){
// There may be several etags, per the RFCs
$etag_candidates=explode(",",$_SERVER['HTTP_IF_NONE_MATCH']);
foreach ($etag_candidates as $cand){
// Remove any whitespace from the header
$cand=trim($cand);
if ("$cand" == "$etag"){
returnNotModified($etag);
}
}
}elseif(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])){
// Convert to epoch
$lastmod = strtotime($mtime);
// Validate the date is a HTTP date
if (validateDate($_SERVER['HTTP_IF_MODIFIED_SINCE'], 'D, d M Y H:i:s T')){
$candtime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ( $lastmod <= $candtime && $lastmod != 0 ){
returnNotModified(false,$mtime);
}
}
}
return true;
}
/** Set the status code to 304.
* Also resend Etag and Last-mod as Apache strips them out when we change the status
*
*
*/
function returnNotModified($etag,$lastmod=false){
header("HTTP/1.1 304 Not Modified",true,304);
// Only include an etag if it revalidated (otherwise Apache will strip the Last-Mod
if ($etag){
header("ETag: $etag");
}
if ($lastmod){
header("Last-Modified: $lastmod");
}
// Close the request
exit();
}
/* Check that a date is in a correct and valid format
* Shamelessly nabbed from http://php.net/manual/en/function.checkdate.php/#113205
*
*/
function validateDate($date, $format = 'Y-m-d H:i:s')
{
// Ensure the date is in GMT (RFC2616)
$date=rtrim($date);
$tz=substr($date,-3,3);
if ($tz != "GMT"){
return false;
}
$d = DateTime::createFromFormat($format, $date);
return $d && $d->format($format) == $date;
}
// Below this is just used for the example
$etag = 'abcdefg';
$last_mod = "Thu, 05 Apr 2018 06:47:00 GMT";
evaluateConditionalRequest($last_mod,$etag);
// OK then, output the test page
// include metadata necessary for revalidation
header("Last-Modified: $last_mod",true);
header("ETag: $etag",true);
?>
<html>
Foo - test page
</html>