Push the limits of your PHP knowledge with this advanced tutorial. Implement techniques including object-oriented programming, regular expressions, and function currying to build a templating system from scratch.
Why Do I Need to Build a Templating System?
The short answer? You don't. So why bother? From the bottom of my geeky little heart, I believe that all developers should constantly be pushing themselves to learn new and/or difficult concepts. It's what makes us smarter, keeps our work interesting, and makes our day-to-day workload seem like less of a burden (because, you know, we're getting so much smarter). To that end, rolling your own templating system gives you the opportunity to hone your PHP chops with brain-bending concepts such as currying and regular expressions. And, hey! You just might find that you could put a templating system to use in one of your future projects.
Step 1 Plan the System
"All developers should constantly be pushing themselves to learn new and/or difficult concepts."
Before we just dive in and start programming, let's figure out exactly what we're trying to do. In building this templating system, we are hoping to:
- Separate HTML markup from our PHP scripts almost completely
- Make data from our back-end scripts more accessible to front-end designers
- Simplify maintenance by abstracting display logic from business logic
What We Need to Build
The templating system we're going to build in this exercise will consist of one class, Template, containing two properties and five methods, and a folder containing the template(s) to be parsed. Other than that, we just need a file for outputting some test data. Sounds pretty simple, right?
How the Templating System Works
To make it really easy for a designer to format the data coming out of our server-side scripts, we're going to create a system that allows for a template that has an optional header and footer with a loop that will be used to format the entries returned from the script. If we strip it down to its most basic state, this system will walk through the following steps:
- Load entries to be parsed as an array of objects
- Load the template file to be used
- Separate the header and footer from the loop
- Find the template tags using regular expressions
- Check if the template tag matches a property in the entry obect
- Replace the template tag with the matching property's data
Step 2 Outline the Class
To make any programming project (or anything for that matter) easier, I like to start by outlining the steps I should take. We'll use this tactic so that when we start developing, we're really only filling in the blanks.
Create the Folder Structure
First, create a folder to contain your project. I called mine templating. Inside the project folder, create two new folders: assets and system. The assets folder will contain the templates for our demo. The system folder will contain the Template class. Inside the assets folder, create two new folders: templates. Guess what it holds!
Create the Main Files
Next, create index.php in the main project folder. This is where we'll test the templating system once it's ready to go, and we'll use it to make sure our individual steps are working along the way as well. For now, however, you can leave it empty. In the system folder, create a new PHP file called class.template.inc.php. Inside, define the class, which we'll call Template, and let's take our steps from above to create a to-do list:
<?php /** * A templating engine * * PHP version 5 * * LICENSE: This source file is subject to the MIT License, available at * http://www.opensource.org/licenses/mit-license.html * * @author Jason Lengstorf <[email protected]> * @copyright 2010 Copter Labs * @license http://www.opensource.org/licenses/mit-license.html MIT License */ class Template { //TODO: Define a class property to store the template //TODO: Define a class property to store the entries //TODO: Write a public method to output the result of the templating engine //TODO: Write a private method to load the template //TODO: Write a private method to parse the template //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
NOTE: The fact that the tag replacement method is static and that the currying function is in there at all is just something you'll have to trust me on for now. I'll explain everything if you stick with me.
Step 3 Define a "Template"
Before we start parsing templates, let's decide what our templates are going to look like. A template should be as simple to use as possible. If we do this right, any semi-competent HTML user should be able to easily create templates. This means keeping our template as close to writing HTML as possible.
What Would the HTML Look Like?
To define our template, let's start by simply mocking up an entry as we might see it on a webpage:
<h3>Other Articles to Check Out</h3> Here are some other articles on the Envato network. <ul id="entries"> <li> <h4><a href="http://net.tutsplus.com/">Some Article</a></h4> <p class="byline">Published on net.tutsplus.com <p class="read-more"><a href="http://net.tutsplus.com/">read full article »</a> </li> </ul><!-- end #entries -->
Great. That's simple enough. So let's separate out the pieces that would vary from article to article:
- URL — the article URL
- Title — the article title
- Publisher — the site that published the article
Use the Variable Data to Choose Template Tags
Using the information we gathered above, we can determine that we need a total of three template tags for the example above.
<h3>Other Articles to Check Out</h3> Here are some other articles on the Envato network. <ul id="entries"> <li> <h4><a href="url">title</a></h4> <p class="byline">Published on site <p class="read-more"><a href="url">read full article »</a> </li> </ul><!-- end #entries -->
But we can't just use the word "title" as the template tag for the title; what if someone uses the word "title" in her markup? It would result in every occurrence of the word being replaced with the entry title — obviously, this isn't the proper behavior. To avoid this, we need to wrap our template tags in something that is not likely to appear in the text. For this example, we'll use curly braces ({}).
<h3>Other Articles to Check Out</h3> Here are some other articles on the Envato network. <ul id="entries"> <li> <h4><a href="{url}">{title}</a></h4> <p class="byline">Published on {site} <p class="read-more"><a href="{url}">read full article »</a> </li> </ul><!-- end #entries -->
By using a template tag that's relatively unlikely to appear in the text of average markup, we can be relatively certain that our templates will work as expected in pretty much all standard situations.
Step 4 Load the Entries
Because I'd like to get right into the internals of the templating engine, we're not going to spend much time on the entries themselves. I'm going to use the Envato API and implement the steps for href="http://net.tutsplus.com/tutorials/php/display-anything-you-want-from-the-envato-api-using-php/">using the Envato API from Drew Douglass. Open index.php in the root of your project folder and insert the following code.
<?php /** * Loads entries from the Envato API for a given site * * @link https://marketplace.envato.com/api/documentation * * @param string $site The site from which entries should be loaded * @return array An array of objects containing entry data */ function load_envato_blog_posts( $site='themeforest' ) { // Set up the request for the Envato API $url = 'http://marketplace.envato.com/api/edge/blog-posts:'.$site.'.json'; // Initialize an empty array to store entries $entries = array(); // Load the data from the API $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); $ch_data = curl_exec($ch); curl_close($ch); // If entries were returned, load them into the array if(!empty($ch_data)) { // Convert the JSON into an array of entry objects $json_data = json_decode($ch_data, TRUE); foreach( $json_data['blog-posts'] as $entry ) { $entries[] = (object) $entry; } return $entries; } else { die('Something went wrong with the API request!'); } }
As you can see in the comments, we're using cURL to send a request to the Envato API and storing the returned data in $ch_data. Then, assuming entries were returned, those entries are converted from the returned JSON format into an array of objects. NOTE: For further info on object-oriented PHP, check out my Nettuts article about object-oriented programming in PHP, my book Pro PHP and jQuery, or a quick explanation of OOP on Wikipedia. The entry objects contain the properties which we can use in our templates. To see this data, add the bold code below into index.php:
<?php echo '<pre>', print_r(load_envato_blog_posts(), TRUE), '</pre>'; /** * Loads entries from the Envato API for a given site * ... */ function load_envato_blog_posts( $site='themeforest' ) {...}
NOTE: Code snipped for brevity. If you load index.php in your browser, you'll see something similar to the following:
Array ( [0] => stdClass Object ( [title] => Interview With "The Man": Jeffrey Way [url] => http://feedproxy.google.com/~r/themeforest/~3/5oZEgpMCn3Q/ [site] => themeforest.net [posted_at] => 2009-12-19 ) [1] => stdClass Object ( [title] => ThemeForest Week in Review [url] => http://feedproxy.google.com/~r/themeforest/~3/fAiw8Xw1Q8U/ [site] => themeforest.net [posted_at] => 2009-12-19 ) ...more entries... )
The class properties — $title, $url, $site, and $posted_at — will correspond to the template tags {title}, {url}, {site}, and {posted_at}. We'll get to exactly how that will work a little later on.
Tie the Entries to the Templating System
You may have noticed that the entries aren't being loaded or stored in the Template class right now. This is because we want our templating system to be compatible with any set of entries, and it's pretty easy to take any set of entries from a database or web service and organize them into an array of objects. However, we do need to store the entries in the templating system so they can be parsed. To keep this nice and simple, simple open up class.template.inc.php and create a public property called $entries. This will store the entries for parsing later.
<?php /** * A templating engine * ... */ class Template { //TODO: Define a class property to store the template /** * Stores the entries to be parsed in the template * @var array */ public $entries = array(); //TODO: Write a public method to output the result of the templating engine //TODO: Write a private method to load the template //TODO: Write a private method to parse the template //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
Step 5 Load the Template File
Our next step is to create a method that will load the template file for parsing. Because this method should only run as part of the overarching templating process, we can make this method private. In the interest of following PEAR naming conventions we'll call this method _load_template(). In addition, we need two new properties to store the template file path as well as the loaded template, which we'll call $template_file and $_template, respectively.
<?php /** * A templating engine * ... */ class Template { /** * Stores the location of the template file * @var string */ public $template_file, /** * Stores the entries to be parsed in the template * @var array */ $entries = array(); /** * Stores the contents of the template file * @var string */ private $_template; //TODO: Write a public method to output the result of the templating engine /** * Loads a template file with which markup should be formatted * * @return string The contents of the template file */ private function _load_template( ) { // Load the template... } //TODO: Write a private method to parse the template //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
NOTE: Don't forget to adjust the $entries property to account for the addition of $template.
Make Sure the File Exists
Our first step in loading the template is to make sure the file exists before attempting to open it. This helps avoid errors, and really, it's just a good idea to be sure. We'll do this using file_exists(). Also, because there's the outside chance that a file's permissions might not allow us to read its contents, we need to check that as well using is_readable(). Because we're trying to make this as simple as possible, we'll be adding a default template in case the provided template doesn't exist or doesn't load properly for some reason. After we've figured out the location of the template file, we can load it into our private $_template property using file_get_contents(). In class.template.inc.php, add the following bold code to _load_template() to load our template (or a default):
<?php /** * A templating engine * ... */ class Template { /** * Stores the location of the template file * @var string */ public $template, /** * Stores the entries to be parsed in the template * @var array */ $entries = array(); /** * Stores the contents of the template file * @var string */ private $_template; //TODO: Write a public method to output the result of the templating engine /** * Loads a template file with which markup should be formatted * ... */ private function _load_template( ) { // Check for a custom template $template_file = 'assets/templates/' . $this->template_file; if( file_exists($template_file) && is_readable($template_file) ) { $path = $template_file; } // Look for a system template else if( file_exists($default_file = 'assets/templates/default.inc') && is_readable($default_file) ) { $path = $default_file; } // If the default template is missing, throw an error else { throw new Exception( 'No default template found' ); } // Load the contents of the file and return them $this->_template = file_get_contents($path); } //TODO: Write a private method to parse the template //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
Step 6 Parse the Template
To parse our template, we need to plan out a list of steps that will be performed to properly handle the data:
- Remove any PHP-style tags from the template
- Extract the main entry loop from the file
- Define a regular expressions to match any template tag
- Curry the function that will replace the tags with entry data
- Extract the header and handle template tags if they exist
- Extract the footer and handle template tags if they exist
- Process each entry and insert its values into the loop
- Return the formatted entries with the header and footer
But First, a Word About Regular Expressions
With our template loaded and a plan of attack, we can start the process of parsing. This is a little trickier because we'll be getting into regular expressions, which can be both intimidating and intoxicating for developers. So before we go nuts, let's take a second. Take a deep breath, and repeat after me: "With great power comes great responsibility. I will only use regular expressions when there isn't a simpler way to accomplish the desired outcome. Because every time I abuse regular expressions, another programmer loses a weekend crying into his fourth Red Bull while trying to decipher the mess I've made." With that promise in mind, let's dig in. Create a new private method in the Template class called _parse_template() to handle the regexes we're about to write.
<?php /** * A templating engine * ... */ class Template { /** * Stores the location of the template file * @var string */ public $template, /** * Stores the entries to be parsed in the template * @var array */ $entries = array(); /** * Stores the contents of the template file * @var string */ private $_template; //TODO: Write a public method to output the result of the templating engine /** * Loads a template file with which markup should be formatted * ... */ private function _load_template( ) {...} /** * Separates the template into header, loop, and footer for parsing * * @param array $extra Additional content for the header/footer * @return string The entry markup */ private function _parse_template( $extra=NULL ) { //TODO: Remove any PHP-style comments from the template //TODO: Extract the main entry loop from the file //TODO: Extract the header from the template if one exists //TODO: Extract the footer from the template if one exists //TODO: Define a regex to match any template tag //TODO: Process each entry and insert its values into the loop //TODO: Curry the function that will replace the tags with entry data //TODO: If extra data was passed to fill in the header/footer, parse it here //TODO: Return the formatted entries with the header and footer } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
Get Ready for Testing
For testing purposes, we'll need three things:
- A template file for testing
- A method to return generated markup from the Template class
- Modifications to index.php that will output the returned markup
First, let's put together a sample template that will test all of the features we're going to build in _parse_template( . In assets/templates/, create a new file called template-test.inc and insert the following:
/* * This is a test comment */ <h2>Template Header</h2> {loop} // Entry data will be processed here This is content that should be displayed. {test} {/loop} /* * This is another block comment */ Template footer.
Next, we need to define our public method to generate markup, which will be called generate_markup(). This method simply calls _load_template() and _parse_template() and outputs the resulting HTML markup. The generate_markup() method will eventually accept additional data that can be inserted into the header or footer of a template, so we'll get ready for that feature as well by adding an argument to the method called $extra.
<?php /** * A templating engine * ... */ class Template { /** * Stores the location of the template file * @var string */ public $template, /** * Stores the entries to be parsed in the template * @var array */ $entries = array(); /** * Stores the contents of the template file * @var string */ private $_template; /** * Generates markup by inserting entry data into the template file * * @param array $extra Extra data for the header/footer * @return string The HTML with entry data inserted into the template */ public function generate_markup( $extra=array() ) { $this->_load_template(); return $this->_parse_template($extra); } /** * Loads a template file with which markup should be formatted * ... */ private function _load_template( ) {...} /** * Separates the template into header, loop, and footer for parsing * ... */ private function _parse_template( $extra=NULL ) {...} //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
Finally, let's modify index.php to output the returned value of generate_markup(). To do this, use require_once to include the Template class file, then create an instance of it. With our new Template object, we can define the template file name and echo the result of generate_markup() to the browser:
<?php // Error reporting is turned up to 11 for the purposes of this demo ini_set("display_errors",1); ERROR_REPORTING(E_ALL); // Exception handling set_exception_handler('exception_handler'); function exception_handler( $exception ) { echo $exception->getMessage(); } // Load the Template class require_once 'system/class.template.inc.php'; // Create a new instance of the Template class $template = new Template; // Set the testing template file location $template->template_file = 'template-test.inc'; // Output the template markup echo $template->generate_markup(); /** * Loads entries from the Envato API for a given site * ... */ function load_envato_blog_posts( $site='themeforest' ) {...}
Now all we have to do is temporarily
output the template contents at the bottom of _parse_template() so we can see what's happening as we parse the template:
<?php /** * A templating engine * ... */ class Template { /** * Stores the location of the template file * @var string */ public $template, /** * Stores the entries to be parsed in the template * @var array */ $entries = array(); /** * Stores the contents of the template file * @var string */ private $_template; /** * Generates markup by inserting entry data into the template file * ... */ public function generate_markup( $extra=array() ) {...} /** * Loads a template file with which markup should be formatted * ... */ private function _load_template( ) {...} /** * Separates the template into header, loop, and footer for parsing * ... */ private function _parse_template( $extra=NULL ) { //TODO: Remove any PHP-style comments from the template //TODO: Extract the main entry loop from the file //TODO: Extract the header from the template if one exists //TODO: Extract the footer from the template if one exists //TODO: Define a regex to match any template tag //TODO: Process each entry and insert its values into the loop //TODO: Curry the function that will replace the tags with entry data //TODO: If extra data was passed to fill in the header/footer, parse it here //TODO: Return the formatted entries with the header and footer // TEMPORARY: return the template after comment removal return $this->_template; } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
Make sure this is working by loading index.php in your browser. It should produce the following:
/* * This is a test comment */ Template Header {loop} // Entry data will be processed here This is content that should be displayed. {test} {/loop} /* * This is another block comment */ Template footer.
Remove Comments from the Template File
Our first regexes will remove any PHP-style comments from a template file. While this isn't a strictly necessary step, keep in mind that we're trying to keep this system as user-friendly as possible. It would be extremely helpful to a potential template builder to know what tags are available, but it's probably not desirable to have those comments in your final markup (not to mention that a PHP-style comment will break HTML layouts). The two comment styles we're going to approach are the recommended comment styles in PHP:
/* * This is a block-level comment */ // This is a one-line comment
First, let's focus on the regex that will capture block-level comments. This needs to match any string starting with /* and ending with */. Right out of the gate, our regex will look like this:
$pattern = '#/\*\*/#';
Because the standard regex delimiter is the forward slash (/), we're going to use an alternative delimiter to reduce the number of escaped characters required in our regex. The number sign (#) is a perfectly valid regex delimiter — though I usually recommend using the standard forward slash for clarity, in certain cases using the standard can decrease the readability of a regular expression. Compare the following:
$pattern = '#/\*\*/#'; // Valid and easy to read $pattern = '/\/\*\*\//'; // Identical in function, but a little harder to read
If we insert this regex into _parse_template() as is, though, it doesn't yield the desired result. Add the bold code below to _parse_template() to see the result of our current regex:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = '#/\*\*/#'; $template = preg_replace($comment_pattern, NULL, $template); //TODO: To-do items snipped for brevity... // TEMPORARY: return the template after comment removal return $template; } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
NOTE: For the remainder of this article, docblock comments will be omitted to save space (unless they're new). The output in your browser doesn't change if you reload index.php; this happens because we haven't added the wildcard character with a modifier to account for zero or more characters between the comment opening and closing (.*). Additionally, we need to account for the fact that block-level comments are usually multi-line, we need to add the s modifier to account for this. Our modified regex should look like this:
$comment_pattern = '#/\*.*\*/#s';
Adjust this in _parse_template(), then reload index.php in your browser. Whoops! The output is:
Template footer.
We forgot to make the wildcard character lazy, so instead of stopping at the end of the first comment, it continued on to the end of the second block comment. This is easy to fix, though: simply add a question mark after the wildcard to make it lazy. It should look like this:
$comment_pattern = '#/\*.*?\*/#s';
Adjust _parse_template(), then reload index.php. Much better!
Template Header {loop} // Entry data will be processed here This is content that should be displayed. {test} {/loop} Template footer.
Next, we need to target inline comments (those starting with two forward slashes (//). Because these aren't multi-line, this will actually be its own regex instead of expanding the block-level comment regex. For this regex, we need to find any text following two forward slashes (//). The only exception to this rule is the two forward slashes in a remote URL (http://) — to exclude this, we'll use a negative lookbehind. The finished regular expression should look like this:
#(?<!:)//.*#
Add this to _parse_template() by changing the variable $comment_pattern to an array with our block-level regex as the first item and the inline comment regex as the second:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); //TODO: To-do items snipped for brevity... // TEMPORARY: return the template after comment removal return $template; } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
Now the comments are properly stripped when you reload index.php in your browser:
Template Header {loop} This is content that should be displayed. {test} {/loop} Template footer.
Separate the Header, Footer, and Loop for Processing
Our next task is to split the template into three sections: the header, the loop, and the footer. These won't necessarily exist
in all cases, so we'll have to check for that as well.
Isolate the Main Entry Loop
First, we'll grab the loop by catching all content between the {loop} and {/loop} template tags. This regex will match the whole template and use a capturing group to identify the loop:
#.*{loop}(.*?){/loop}.*#is
Modify _parse_template() as shown in bold to test that the entry loop is being extracted properly:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); // Extract the main entry loop from the file $pattern = '#.*{loop}(.*?){/loop}.*#is'; $entry_template = preg_replace($pattern, "$1", $template); //TODO: To-do items snipped for brevity... // TEMPORARY: return the loop after isolating it return $entry_template; } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
NOTE: Don't forget to alter the function to return $entry_template so you can see the proper output. By using preg_replace() to "replace" the whole template with just the captured loop template, we've successfully isolated the main loop. Reload index.php in your browser and you should see the following:
This is content that should be displayed. {test}
Isolate the Header
Next, let's get the header out of the template. The regex to do this will be similar to the one that grabbed the main loop, but this time we're going to capture the content before the {loop} template tag. To match the header, we need to start from the beginning of the template and capture everything up until the {loop} tag. Since we're using preg_replace() to extract the header, we also need to match everything after the {loop} tag as well to make sure it gets removed when the replacement occurs. This regex, when it's completed, should look like this:
/^(.*)?{loop.*$/is
Because some templates won't require a header, we also need to check that the data returned in header isn't the whole template. If that happens, the header should be set to NULL to avoid duplicated data. Modify _parse_template() with the bold code and set it to return the extracted header:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); // Extract the main entry loop from the file $pattern = '#.*{loop}(.*?){/loop}.*#is'; $entry_template = preg_replace($pattern, "$1", $template); // Extract the header from the template if one exists $header = trim(preg_replace('/^(.*)?{loop.*$/is', "$1", $template)); if( $header===$template ) { $header = NULL; } //TODO: To-do items snipped for brevity... // TEMPORARY: return the header after isolating it return $header; } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
As expected, reloading index.php in your browser will result in the header data being displayed:
Template Header
Isolate the Footer
The footer is very similar to the header in how it's extracted, except this time we're capturing the data after the {/loop} tag and making sure it doesn't match the whole template using this regex:
#^.*?{/loop}(.*)$#is
Plug this into _parse_template() and set the method to return the footer content just like we did with the header using the bold code:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); // Extract the main entry loop from the file $pattern = '#.*{loop}(.*?){/loop}.*#is'; $entry_template = preg_replace($pattern, "$1", $template); // Extract the header from the template if one exists $header = trim(preg_replace('/^(.*)?{loop.*$/is', "$1", $template)); if( $header===$template ) { $header = NULL; } // Extract the footer from the template if one exists $footer = trim(preg_replace('#^.*?{/loop}(.*)$#is', "$1", $template)); if( $footer===$template ) { $footer = NULL; } //TODO: To-do items snipped for brevity... // TEMPORARY: return the footer after isolating it return $footer; } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
Reload the index file to see the footer output:
Template footer.
Identify Template Tags with Regular Expressions
The next step in our process is to put together a regex that will match any template tag so that we can replace them with entry data. Unlike the others we've written, this one shouldn't match the whole template. This pattern should match only the template tag that will be replaced. To accomplish this, we can use the shorthand to match any word character (\w is equivalent to [A-Za-z0-9_]) and match one or more characters between curly braces. The complete regex looks like this:
/{(\w+)}/
We'll verify that this works in just a bit, but for now let's just define the pattern for later by adding the bold code to _parse_template():
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); // Extract the main entry loop from the file $pattern = '#.*{loop}(.*?){/loop}.*#is'; $entry_template = preg_replace($pattern, "$1", $template); // Extract the header from the template if one exists $header = trim(preg_replace('/^(.*)?{loop.*$/is', "$1", $template)); if( $header===$template ) { $header = NULL; } // Extract the footer from the template if one exists $footer = trim(preg_replace('#^.*?{/loop}(.*)$#is', "$1", $template)); if( $footer===$template ) { $footer = NULL; } // Define a regex to match any template tag $tag_pattern = '/{(\w+)}/'; //TODO: To-do items snipped for brevity... } //TODO: Write a static method to replace the template tags with entry data //TODO: Write a private currying function to facilitate tag replacement }
With all of our regular expressions ready to rock, we can move on to the next big task.
Step 7 Get Ready for Template Tag Replacement: Currying
In order to replace our template tags with the properties from our entries, we need to use a concept called currying, which is the act of making a function that takes multiple arguments into a chain of functions that accept a single argument.
Wait. What Did You Just Say?
Currying is kind of a weird concept to get your head around, so let's take a second to go over exactly how it works. The goal of a currying function is to make it possible to supply one argument at a time to a function that requires multiple aruguments without causing errors. So, for instance, let's look at a simple math function:
function add( $x, $y ) { return $x + $y; }
If we wanted to call this function normally, we'd simply do the following:
echo add(1, 2); // Output: 3
But let's say that — for whatever reason — we needed to call the add() function incrementally; we can't simply add one argument now and another later (by calling add(1)). That issues a warning:
Warning: Missing argument 2 for add()... Notice: Undefined variable: y in ...
So we need an intermediate step that will check if the proper number of arguments were passed. If so, the function executes as usual. However, if there are too few arguments, a new function will be returned with the function name and the first argument stored. This way, when the second argument is passed the original function can be called properly. Using our add() function as an example and assuming that we can curry this function using the imaginary function curry(), we can demonstrate this process:
/* * Start by currying the function - argument 1 is the function name, * argument 2 is the number of arguments expected */ $curried = curry('add', 2); // Returns the curried add function $partial = $curried(1); // Returns a function with the argument '1' stored echo $partial(2); // Output: 3 // Try passing the expected number of arguments to the curried function echo $curried(2, 2); // Output: 4
Write the Currying Method
Now that we know how currying works, let's start writing the function. The currying function itself will always return a function, which we'll do using create_function(). The created function will check if the proper number of arguments exist and execute the curried function as usual if so using call_user_func_array(). If there aren't enough arguments, another function will be returned using create_function(). Because putting all of this together requires creating functions within created functions, there's a lot of escaping. This makes our currying method look more confusing than it really is. Add it to the Template class with the following bold code:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) {...} //TODO: Write a static method to replace the template tags with entry data /** * A currying function * * Currying allows a function to be called incrementally. This means that if * a function accepts two arguments, it can be curried with only one * argument supplied, which returns a new function that will accept the * remaining argument and return the output of the original curried function * using the two supplied parameters. * * Example: function add($a, $b) { return $a + $b; } $func = $this->_curry('add', 2); $func2 = $func(1); // Stores 1 as the first argument of add() echo $func2(2); // Executes add() with 2 as the second arg and outputs 3 * @param string $function The name of the function to curry * @param int $num_args The number of arguments the function accepts * @return mixed Function or return of the curried function */ private function _curry( $function, $num_args ) { return create_function('', " // Store the passed arguments in an array \$args = func_get_args(); // Execute the function if the right number of arguments were passed if( count(\$args)>=$num_args ) { return call_user_func_array('$function', \$args); } // Export the function arguments as executable PHP code \$args = var_export(\$args, 1); // Return a new function with the arguments stored otherwise return create_function('',' \$a = func_get_args(); \$z = ' . \$args . '; \$a = array_merge(\$z,\$a); return call_user_func_array(\'$function\', \$a); '); "); } }
How Does Currying Apply to the Templating System?
Right now it might not be clear how all of this applies to the templating system. When we get to the next step we'll go over this in detail, but in short, we need to be able to call a function with two arguments in order to replace the template tags: one argument is the entry from which data should be pulled, and the other is the template tag to be replaced. Since we're using regular expressions to replace the tags, we need to use preg_replace_callback() to make the replacements. However, since the callback passed to that function can only accept one argument — the matched text — we need to pass a curried function that already has the entry stored inside of it. Make sense? Let's make it happen!
Step 8 Replace Template Tags with Matching Entry Data
All the pieces are in place. Now we just need to connect the dots and get it done.
Write the Tag Replacing Method
Replacing tags is fairly simple on its own: take the matched text from the template tag and see if a property exists in the entry object by that name. If so, return the data stored in said property; if not, just return the template tag so the designer can see that something went wrong (or, in edge cases, the odd string that was wrapped in curly braces doesn't break). This function is going to be called replace_tags(), and it will be static so that it can be passed to the currying function as a valid callback. Add it to the Template class using the bold code below:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) {...} /** * Replaces template tags with the corresponding entry data * * @param string $entry A serialized entry object * @param array $params Parameters for replacement * @param array $matches The match array from preg_replace_callback() * @return string The replaced template value */ public static function replace_tags($entry, $matches) { // Unserialize the object $entry = unserialize($entry); // Make sure the template tag has a matching array element if( property_exists($entry, $matches[1]) ) { // Grab the value from the Entry object return $entry->{$matches[1]}; } // Otherwise, simply return the tag as is else { return "{".$matches[1]."}"; } } private function _curry( $function, $num_args ) {...} }
NOTE: The call to unserialize() at the top of this method is due to an issue with passing an object through a currying function. We'll serialize the object in the next step.
Modify the Template Parsing Method to Replace Template Tags
To complete our templating system, we first need to get our curried callback function ready for use with all of the replacement calls. This is done by currying Template::replace_tags() and storing it in a variable called $callback. Add this to _parse_template() with the bold code below:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); // Extract the main entry loop from the file $pattern = '#.*{loop}(.*?){/loop}.*#is'; $entry_template = preg_replace($pattern, "$1", $template); // Extract the header from the template if one exists $header = trim(preg_replace('/^(.*)?{loop.*$/is', "$1", $template)); if( $header===$template ) { $header = NULL; } // Extract the footer from the template if one exists $footer = trim(preg_replace('#^.*?{/loop}(.*)$#is', "$1", $template)); if( $footer===$template ) { $footer = NULL; } // Define a regex to match any template tag $tag_pattern = '/{(\w+)}/'; // Curry the function that will replace the tags with entry data $callback = $this->_curry('Template::replace_tags', 2); //TODO: To-do items snipped for brevity... } public static function replace_tags($entry, $matches) {...} private function _curry( $function, $num_args ) {...} }
Next, we need to set up a loop to go through each entry object in the $entries array. With each entry, we need to call preg_replace_callback() with the template tag regex as the first argument, the callback with the serialized entry object as the second argument, and the loop as the third argument. The markup returned from each call should be appended to a variable called $markup, which stores all of the entry markup to be output to the browser. Add this to _parse_template() with the bold code:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); // Extract the main entry loop from the file $pattern = '#.*{loop}(.*?){/loop}.*#is'; $entry_template = preg_replace($pattern, "$1", $template); // Extract the header from the template if one exists $header = trim(preg_replace('/^(.*)?{loop.*$/is', "$1", $template)); if( $header===$template ) { $header = NULL; } // Extract the footer from the template if one exists $footer = trim(preg_replace('#^.*?{/loop}(.*)$#is', "$1", $template)); if( $footer===$template ) { $footer = NULL; } // Define a regex to match any template tag $tag_pattern = '/{(\w+)}/'; // Curry the function that will replace the tags with entry data $callback = $this->_curry('Template::replace_tags', 2); // Process each entry and insert its values into the loop $markup = NULL; for( $i=0, $c=count($this->entries); $i<$c; ++$i ) { $markup .= preg_replace_callback( $tag_pattern, $callback(serialize($this->entries[$i])), $entry_template ); } //TEMPORARY: Output the markup after replacing template tags return $markup; } public static function replace_tags($entry, $matches) {...} private function _curry( $function, $num_args ) {...} }
Before we can test this, we need to create an entry so that the engine has something to loop through. In index.php, add a dummy entry with a property called
$test to match the template tag in our testing template file:
<?php // Error reporting is turned up to 11 for the purposes of this demo ini_set("display_errors",1); ERROR_REPORTING(E_ALL); // Exception handling set_exception_handler('exception_handler'); function exception_handler( $exception ) { echo $exception->getMessage(); } // Load the Template class require_once 'system/class.template.inc.php'; // Create a new instance of the Template class $template = new Template; // Set the testing template file location $template->template_file = 'template-test.inc'; $template->entries[] = (object) array( 'test' => 'This was inserted using template tags!' ); // Output the template markup echo $template->generate_markup(); /** * Loads entries from the Envato API for a given site * ... */ function load_envato_blog_posts( $site='themeforest' ) {...}
With a dummy entry present, we can test the template tag replacement by reloading index.php in our browser. The output should read:
This is content that should be displayed. This was inserted using template tags!
This means that we've effectively created a templating engine! However, we're not quite done yet: we still need to add the ability to replace template tags in the header and footer of our template file.
Replace Template Tags in the Header and Footer of the Template
The process for replacing header and footer template tags is identical to the process for those in the loop, but different data is necessary in order to do so. You may remember that we included an argument called $extra when we were writing the generate_markup() and _parse_template() methods; this variable is to be used as an object that will store data for replacing the header and footer template tags. For our purposes, $extra can contain two properties, $header and $footer, both of which will store an object whose properties will be used to replace template tags in the corresponding section of the template. Obviously, if no header or footer tags exist, then no data will be stored in $extra for processing. For that reason, we start by checking if $extra is an object. If so, we'll loop through its properties and run preg_replace_callback() with the template tag regex, the callback after being passed the serialized header or footer object, and the header or footer section of the template. Add the bold code below to complete our templating system:
<?php class Template { public $template, $entries = array(); private $_template; public function generate_markup( $extra=array() ) {...} private function _load_template( ) {...} private function _parse_template( $extra=NULL ) { // Create an alias of the template file property to save space $template = $this->_template; // Remove any PHP-style comments from the template $comment_pattern = array('#/\*.*?\*/#s', '#(?<!:)//.*#'); $template = preg_replace($comment_pattern, NULL, $template); // Extract the main entry loop from the file $pattern = '#.*{loop}(.*?){/loop}.*#is'; $entry_template = preg_replace($pattern, "$1", $template); // Extract the header from the template if one exists $header = trim(preg_replace('/^(.*)?{loop.*$/is', "$1", $template)); if( $header===$template ) { $header = NULL; } // Extract the footer from the template if one exists $footer = trim(preg_replace('#^.*?{/loop}(.*)$#is', "$1", $template)); if( $footer===$template ) { $footer = NULL; } // Define a regex to match any template tag $tag_pattern = '/{(\w+)}/'; // Curry the function that will replace the tags with entry data $callback = $this->_curry('Template::replace_tags', 2); // Process each entry and insert its values into the loop $markup = NULL; for( $i=0, $c=count($this->entries); $i<$c; ++$i ) { $markup .= preg_replace_callback( $tag_pattern, $callback(serialize($this->entries[$i])), $entry_template ); } // If extra data was passed to fill in the header/footer, parse it here if( is_object($extra) ) { foreach( $extra as $key=>$props ) { $$key = preg_replace_callback( $tag_pattern, $callback(serialize($extra->$key)), $$key ); } } // Return the formatted entries with the header and footer reattached return $header . $markup . $footer; } public static function replace_tags($entry, $matches) {...} private function _curry( $function, $num_args ) {...} }
If you reload index.php in your browser, you'll see the following output:
Template Header This is content that should be displayed. This was inserted using template tags! Template footer.
The last thing to do is to test the header and footer template tag replacement. Open template-test.inc and add two new template tags, one in the header and one in the footer:
/* * This is a test comment */ <h2>Template Header</h2> {header_stuff} {loop} // Entry data will be processed here This is content that should be displayed. {test} {/loop} /* * This is another block comment */ Template footer. {footerStuff}
Next, go back to index.php and add a new object called $extra with two objects
stored in its $header and $footer properties that have properties corresponding to the new template tags:
<?php // Error reporting is turned up to 11 for the purposes of this demo ini_set("display_errors",1); ERROR_REPORTING(E_ALL); // Exception handling set_exception_handler('exception_handler'); function exception_handler( $exception ) { echo $exception->getMessage(); } // Load the Template class require_once 'system/class.template.inc.php'; // Create a new instance of the Template class $template = new Template; // Set the testing template file location $template->template_file = 'template-test.inc'; $template->entries[] = (object) array( 'test' => 'This was inserted using template tags!' ); $extra = (object) array( 'header' => (object) array( 'header_stuff' => 'Some extra content.' ), 'footer' => (object) array( 'footerStuff' => 'More extra content.' ) ); // Output the template markup echo $template->generate_markup($extra); /** * Loads entries from the Envato API for a given site * ... */ function load_envato_blog_posts( $site='themeforest' ) {...}
NOTE: Don't forget to pass $extra to generate_markup()! Save these changes, reload the file in your browser, and you'll see the following:
Template Header Some extra content. This is content that should be displayed. This was inserted using template tags! Template footer. More extra content.
Step 9 Use Real Entries
As a final exercise, let's use some real entries from the Envato Marketplace and design a template to display them.
Create a Template
For a template, let's create a new on in the templates folder called entry-list.inc. Inside, add the following code:
/** * This template has the following tags available: * title Article title * url Permalink of the article * site Site on which the article was originally published * posted_at Original posting date */ <h3>Entry Short List</h3> <ol id="entry-list"> // This is the main entry loop {loop} <li><a href="{url}">{title}</a> (published on {site})</li> {/loop} </ol><!-- end #entry-list -->
Load Real Entries and Use the New Template
All we need to do to load real entries from the Envato API is call the function we wrote earlier in this tutorial. In
index.php, alter the code to use the new template file and to load the latest entries from audiojungle:
<?php // Error reporting is turned up to 11 for the purposes of this demo ini_set("display_errors",1); ERROR_REPORTING(E_ALL); // Exception handling set_exception_handler('exception_handler'); function exception_handler( $exception ) { echo $exception->getMessage(); } ?> <!DOCTYPE html> <html lang="en"> <head> <meta http-equiv="Content-Type" content="text/html;charset=utf-8" /> <!-- Meta information --> <title>Demo: Roll Your Own Templating System in PHP</title> <meta name="description" content="A demo of the templating system by Jason Lengstorf" /> </head> <body> <?php // Load the Template class require_once 'system/class.template.inc.php'; // Create a new instance of the Template class $template = new Template; // Set the testing template file location $template->template_file = 'entry-list.inc'; // Load sample entries from Envato $template->entries = load_envato_blog_posts('audiojungle'); // Output the template markup echo $template->generate_markup(); ?> </body> </html> <?php /** * Loads entries from the Envato API for a given site * ... */ function load_envato_blog_posts( $site='themeforest' ) {...}
NOTE: I added in a doctype declaration and basic HTML tags to avoid character encoding issues.
Summary
At this point, you've successfully combined object-oriented PHP, regular expressions, and function currying into an easy-to-use templating system. The techniques in this tutorial can be added to your development arsenal for use in future projects, and hopefully you're feeling like a better developer right about now. Did you spot a shortcut I missed? Can you think of a way to improve the templating system? How do you feel about using this system in a project to help keep markup separate from business logic? Let me know your thoughts in the comments!
Comments