Building a Custom PHP Template Engine, Part 4: Control Structures

September 22nd, 2010 No comments

Today we are going to take a look at control structures and how they fit into our custom template engine. If you haven’t done so already, you should familiarize yourself with our previously related articles to get caught up to speed:

  1. Building a Custom PHP Template Engine, Part 1
  2. Building a Custom PHP Template Engine, Part 2: Caching and Compiling
  3. Building a Custom PHP Template Engine, Part 3: Resource Types

A control structure depicts the flow of code in your script or program. Control structures can be conditional statements: if, elseif, and else; they can be loops: for, foreach, while, and do-while; they can be function calls and more. We are going to alter our Template class to support these with our own syntax. Much like our variable syntax, {$varname}, our control structures should also start and end with curly braces. Most of our control structures, however, will require an ending tag, and will encapsulate a block of code or text inside. For instance: {if $this->GetVar(‘varname’) == ‘test’} this is encapsulated {/if}. Our template modifications will support the following structures: if, elseif, else, for, foreach, while, do-while, php code blocks, and php function calls.

Some Examples

The For Loop:

?View Code TEMPLATE
{for $i = 0; $i < 5; $i++}
	<p>{$i} is an {if $i % 2 == 0}even{else}odd{/if} number</p>
{/for}
 
The Result:
--
<p>0 is an even number</p> 
 
<p>1 is an odd number</p> 
 
<p>2 is an even number</p> 
 
<p>3 is an odd number</p> 
 
<p>4 is an even number</p> 
 
<p>5 is an odd number</p>

The For-Each Loop:

?View Code TEMPLATE
{foreach array('Name'=>'PHP Professional', 'Occupation'=>'PHP Software Engineer', 'Hobby'=>'Blogging') as $key => $value}
	<p>{$key} = {$value}</p>
{/foreach}
 
The Result:
--
<p>Name = PHP Professional</p> 
 
<p>Occupation = PHP Software Engineer</p> 
 
<p>Hobby = Blogging</p>

Using Continue and Break:

?View Code TEMPLATE
{for $i = 0; $i < 10; $i++}
	{if $i == 2}
		<p>continue on 2</p>
		{continue}
	{/if}
	<p>the current iteration is {$i}</p>
	{if $i == 5}
		<p>it is time to break!</p>
		{break}
	{/if}
{/for}
 
The Result:
--
<p>the current iteration is 0</p> 
 
<p>the current iteration is 1</p> 
 
<p>continue on 2</p> 
 
<p>the current iteration is 3</p> 
 
<p>the current iteration is 4</p> 
 
<p>the current iteration is 5</p> 
 
<p>it is time to break!</p

If you look closely, you should notice that the normal syntax applies. With for-loops, where PHP wants for ($i = 0; $i < 10; $i++) { ...code... } you say in your template, {for $i = 0; $i < 10; $i++} ...code... {/for}. With if-conditions, where PHP wants if ( condition ) { ...code... } you say in your template, {if condition} ...code... {/if}. Going about it this way makes the search and replace method that much easier. Any valid php code is valid, only the way you declare the control structure is different.

Modifications

In order to use control structures in our templates, we need to change the way the engine parses and returns data. Mainly, each template needs to be compiled and then executed. This is required because the control structures need to be changed into PHP syntax. The only way to get PHP to run the syntax is to execute the file. We can execute the file by including it with output buffering, just like we do for normally compiled files. So the changes to our Template class will be a result of this.

Our ‘Parse’ method becomes ‘Compile’, since it’s always compiled and is no longer just ‘parsed’. Any parsed or cached version of our template is the result of the executed compiled version. Our new ‘Compile’ method needs to use regular expressions to find and replace the template control structures into PHP syntax. We need to be careful with this though, we want to search the content as little as possible. For this reason, we provide an additional boolean flag that controls whether or not the class should parse control structures. There may be cases where you know that there are no control structures, or don’t want to allow control structures at all, and in those situations, the class should not search for them. The boolean flag we will provide is, $template->parse_control_structures, and this is true by default. We also have compiled templates in place already, so the class only compiles once the template file is created and after it gets modified. If there is a compiled version already available, we just execute that file instead of searching and replacing again. With these changes, it means that you no longer have the option to compile or not compile the template. The template is always compiled, so the boolean flag $template->compiling gets removed. You still have control over whether or not the templates get cached, via $template->caching.

Our ‘Fetch’ method changes slightly, in order to accomodate the new ‘compile-always’ changes, and some method names change to avoid confusion: CompileTemplate and CacheTemplate become SaveCompiledTemplate and SaveCachedTemplate. Otherwise, everything else stays the same.

It’s important to point out that, if you wish to refer to a variable which has been assigned via AssignVar in your control structures, like an if statement, or a while loop, you need to refer to it via, $this->GetVar(‘varname’). The compiled version is executed within the scope of the ‘Fetch’ method, and so ‘$this’, when used in your template, refers to the instantiated template object which is fetching the template. If you wish to refer to a global variable, use $GLOBALS['varname']. For instance, {if $this->GetVar(‘somevar’) == ‘this value’} do this {else} do that {/if} OR {if $GLOBALS['my_global_var'] == true} do this {/if} OR {if isset($_SESSION['idUser'])} show this to logged in users {else} show a you must log in message {/if}.

I’ve pulled out the ‘Compile’ function, so you can look at the regular expressions before looking at the rest of the code:

public function Compile( $str )
{
	// replace assigned variables first
	$search = array();
	$replace = array();
	foreach ( $this->variables as $key => $value )
	{
		$search[] = '{$'. $key .'}';
		$replace[] = '<?php echo $this->GetVar("'. addslashes($key) .'");?>';
	}
	$str = str_replace($search, $replace, $str);
 
	// if we're not parsing control structures, there's no need to continue, return the parsed str
	if ( !$this->parse_control_structures )
		return $str;
 
	// replace control-structures, predefined blocks and tags (break, continue), other variables that were not assigned before-hand, and other misc. functions
	$replaced = preg_replace(
		array(
			// find PHP blocks
			'/\{php\}(.*?)\{\/php\}/s' ,
 
			// find loops and loop-controls like continue and break
			'/\{(for|foreach|while)\s([^\}]+)\}(.*?)\{\/(for|foreach|while)\s*\}/s' ,
			'/\{dowhile\s([^\}]+)\}(.*?)\{\/dowhile\s*\}/s' ,
			'/\{break\s*\}/' ,
			'/\{continue\s*\}/' ,
 
			// find if-elseif-else and endif statements
			'/\{if\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{elseif[^\}]+\}|\{else\s*\})/s' ,
			'/\{elseif\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{else\s*\})/s' ,
			'/\{else\s*\}(.*?)(\{\/if\s*\})/s' ,
			'/\{\/if\s*\}/' ,
 
			// find variables
			'/\{\$([^\}|\:]+)\}/' ,
 
			// find php functions {func_name arg, list} - {var_dump array(1,2,3)}
			'/\{([^\s\}$]+)\s?(.*?)\}/'
		) ,
		array(
			// put PHP code inside php tags
			'<?php \1 ?>' ,
 
			// replace loop template syntax with PHP template syntax
			'<?php \1(\2):?> \3 <?php end\1;?>' ,
			'<?php do{?> \2 <?php }while(\1);?>' ,
 
			// replace break and continue
			'<?php break;?>' ,
			'<?php continue;?>' ,
 
			// replace if statements
			'<?php if (\1):?>\2\3' ,
			'<?php elseif (\1):?>\2\3' ,
			'<?php else:?>\1\2' ,
			'<?php endif;?>' ,
 
			// replace variables, but allow vars that have not been assigned via AssignVar() so we use isset() to check
			'<?php echo (isset($\1)?$\1:$this->GetVar("\1"));?>' ,
 
			// replace PHP functions - {var_dump array(1,2,3)} becomes <?php var_dump(array(1,2,3));?>
			'<?php \1(\2);?>'
		) ,
		$str
	);
 
	// if the regular expression succeeded, use the replaced content, otherwise, use $str which has our pre-assigned variables replaced
	return $replaced : $replaced : $str;
}

The Class

Here is our modified class:

<?php
 
class Template
{
	/**
	* TEMPLATE_DIR is a constant which holds the path to the directory where the original template files are stored
	*
	* @var string path to template directory
	**/
	const TEMPLATE_DIR = 'TemplateEngine/templates';
 
	/**
	* COMPILE_DIR is a constant which holds the path to the directory where the compiled template files are stored
	*
	* @var string path to compiled directory
	**/
	const COMPILE_DIR = 'TemplateEngine/templates_compiled';
 
	/**
	* CACHE_DIR is a constant which holds the path to the directory where the cached template files are stored
	*
	* @var string path to cached directory
	**/
	const CACHE_DIR = 'TemplateEngine/templates_cached';
 
	/**
	* RESOURCE_DIR is a constant which holds the path to the directory where resource types may be declared in files
	* 
	* @var string path to the directory where pre-defined template resource types are stored
	*/
	const RESOURCE_DIR = 'TemplateEngine/template_resource_types';
 
	/**
	* This stores the resource types that have been loaded from files in the pre-determined location (self::RESOURCE_DIR). 
	* This is here so we don't have to look for them on each instantiation, only the first of each page request
	* 
	* @var array
	*/
	private static $LoadedResourceTypes = array();
 
	/**
	* This is an associative array which holds the variable names (keys) and the values to parse in the template file
	*
	* @var array - associative array
	**/
	private $variables = array();
 
	/**
	* Associative array of resource-types that have been registered with this class
	* 
	* @var array - array('type_name'=>array('content_callback_function','datestamp_callback_function'))
	*/
	private $resource_types = array();
 
	/**
	* This is a boolean indicating whether or not this class should cache templates or use cached templates when fetched - false by default
	*
	* @var boolean false by default
	**/
	public $caching = false;
 
	/**
	* This is a boolean indicating whether or not this class should re-compile the template if it is already compiled, regardless of its datestamps
	*
	* @var boolean
	**/
	public $recompile = false;
 
	/**
	* This is a boolean indicating whether or not this class should re-cache the template if it is already cached, regardless of its datestamps
	* 
	* @var boolean
	*/
	public $recache = false;
 
	/**
	* This controls whether or not the template class should look for and replace template control structure syntax into php syntax - true by default
	* If you know you're not going to use control-structures, turn this off to save processing time
	* 
	* @var boolean true to parse control structures, false otherwise - true by default
	*/
	public $parse_control_structures = true;
 
	/**
	* Our constructor method - this will load resource types from files and handle any other procedures we may need 
	* 
	*/
	public function __construct()
	{
		// load pre-defined resource types from files
		$this->LoadResourceTypes();
 
		// register our default 'file' resource type
		$this->AddResourceType('file', array($this, 'FetchFileContent'), array($this, 'FetchFileDatetime'));
	}
 
	/**
	* This loads resource types from files in self::RESOURCE_DIR
	* 
	* @return boolean
	*/
	private function LoadResourceTypes()
	{
		// only read the directory once per request
		if ( !empty(self::$LoadedResourceTypes) )
		{
			$this->resource_types = self::$LoadedResourceTypes;
			return true;
		}
 
		// make sure we have a valid directory
		if ( !is_dir(self::RESOURCE_DIR) )
			return false;
 
		// read all files in the directory
		if ( $dir = opendir(self::RESOURCE_DIR) )
		{
			while ( false !== ($file = readdir($dir)) )
			{
				if ( !is_dir(self::RESOURCE_DIR .'/'. $file) )
				{
					// use pathinfo to retrieve the filename without the extension - introduced in PHP 5.2.0
					$filename = pathinfo($file, PATHINFO_FILENAME);
 
					/**
					* execute the file to retrieve the callback functions
					* the function names must follow a specific naming convention
					* 
					* content functions - function filename_template_content( $template_name ) { } - should return the template content or (bool)false
					* datetime functions - function filename_template_datetime( $template_name ) { } - should return the last-modified time as a unix-timestamp or (bool)false
					*/
 
					// use output buffering so nothing outputs to the browser
					ob_start();
					include self::RESOURCE_DIR .'/'. $file;
					ob_end_clean();
 
					// make sure the callback functions exist
					if ( !is_callable($filename .'_template_content') )
					{
						throw new Exception($file .' does not have a valid callback function for fetching content.');
						continue;
					}
 
					if ( !is_callable($filename .'_template_datetime') )
					{
						throw new Exception($file .' does not have a valid callback function for fetching a last-modified date.');
						continue;
					}
 
					// save the resource type
					$this->resource_types[ $filename ] = self::$LoadedResourceTypes[ $filename ] = array($filename .'_template_content', $filename .'_template_datetime');
				}
			}
			closedir($dir);
		}
 
		return true;
	}
 
	/**
	* Display a fetched template - uses 'print'
	* 
	* @param string $name the name of the template to display
	* @param string $type the resource type we are using to fetch the template - defaults to 'file'
	*/
	public function Display( $name , $type='file' )
	{
		$contents = $this->Fetch($name, $type);
		print $contents;
	}
 
	/**
	* Fetch a template
	* 
	* @param string $name the filename of the template to fetch
	* @param string $type the resource type - defaults to 'file'
	* @return mixed (bool)false or string contents of the parsed file
	*/
	public function Fetch( $name , $type='file' )
	{
		// fetch the last-modified time of the template here, so it's not done multiple times
		$template_mtime = $this->FetchDatetime($name, $type);
 
		if ( $this->caching && !$this->recache && $this->IsCached($name, $type, $template_mtime) )
			return file_get_contents(self::CACHE_DIR .'/'. $type .'_'. $name .'.php');
 
		if ( $this->recompile || !$this->IsCompiled($name, $type, $template_mtime) )
		{
			$contents = $this->FetchContent($name, $type);
			if ( !$contents )
			{
				throw new Exception('Contents for '. $name .' could not be fetched.');
				return false;
			}
			// we always compile the template if it's not compiled already
			$compiled = $this->Compile($contents);
			$this->SaveCompiledTemplate($name, $compiled, $type);
			unset($compiled);
			unset($contents);
		}
 
		// make sure the template doesn't overwrite our $type and $name variables - since it's executed within this scope
		$__template_name__ = $name;
		$__template_type__ = $type;
		unset($name);
		unset($type);
 
		// our 'parsed' version of the template is the result of the executed 'compiled' file
		ob_start();
		include self::COMPILE_DIR .'/'. $__template_type__ .'_'. $__template_name__ .'.php';
		$parsed = ob_get_clean();
 
		if ( $this->caching )
			$this->SaveCachedTemplate($__template_name__, $parsed, $__template_type__);
 
		return $parsed;
	}
 
	/**
	* Retrieve the content for a registered resource type
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @return mixed the file contents or (bool)false
	*/
	public function FetchContent( &$name , $type )
	{
		if ( !isset($this->resource_types[$type]) )
			return false;
 
		$content = call_user_func($this->resource_types[$type][0], $name);
		return $content ? $content : false;
	}
 
	/**
	* Retrieve the last-modified unix-timestamp for a registered resource type
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @return mixed (int)last-modified as a unix-timestamp or (bool)false
	*/
	public function FetchDatetime( &$name , $type )
	{
		if ( !isset($this->resource_types[$type]) )
			return false;
 
		$datetime = call_user_func($this->resource_types[$type][1], $name);
		return $datetime ? $datetime : false;
	}
 
	/**
	* Retrieve the content for a 'file' template type - this is for our default resource type
	* 
	* @param string $name the template name or file-name - this is by reference, because we may change the file name with a default file extension
	* @return mixed the file contents or (bool)false
	*/
	public function FetchFileContent( &$name )
	{
		$name = trim($name, '/');
		if ( !file_exists(self::TEMPLATE_DIR .'/'. $name) )
		{
			$ext = pathinfo($name, PATHINFO_EXTENSION);
			if ( empty($ext) )
			{
				if ( !file_exists(self::TEMPLATE_DIR .'/'. $name .'.tpl') )
					return false;
 
				$name = $name .'.tpl';
			}
		}
		return file_get_contents(self::TEMPLATE_DIR .'/'. $name);
	}
 
	/**
	* Retrieve the last-modified date for a 'file' template type - this is for our default resource type
	* 
	* @param string $name the template name or file-name - this is by reference, because we may change the file name with a default file extension
	* @return mixed the last-modified date as a unix-timestamp or (bool)false
	*/
	public function FetchFileDatetime( &$name )
	{
		$name = trim($name, '/');
		if ( !file_exists(self::TEMPLATE_DIR .'/'. $name) )
		{
			$ext = pathinfo($name, PATHINFO_EXTENSION);
			if ( empty($ext) )
			{
				if ( !file_exists(self::TEMPLATE_DIR .'/'. $name .'.tpl') )
					return false;
 
				$name = $name .'.tpl';
			}
		}
		return filemtime(self::TEMPLATE_DIR .'/'. $name);
	}
 
	/**
	* See if a template-file is compiled already
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $type the resource type - defaults to 'file'
	* @param mixed $template_mtime should be an integer unix-timestamp of the last-modified time or null - if null, it will be fetched in this function
	* @return boolean
	*/
	public function IsCompiled( &$name , $type='file' , $template_mtime=null )
	{
		if ( is_null($template_mtime) )
			$template_mtime = $this->FetchDatetime($name, $type);
 
		if ( $template_mtime === false )
			return false;
 
		if ( !file_exists(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php') )
			return false;
 
		if ( false === ($compiled_mtime = filemtime(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php')) )
			return false;
 
		return ($compiled_mtime >= $template_mtime);
	}
 
	/**
	* See if a template is cached already
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $type the resource type - defaults to 'file'
	* @param mixed $template_mtime should be an integer unix-timestamp of the last-modified time or null - if null, it will be fetched in this function
	* @return boolean
	*/
	public function IsCached( &$name , $type='file' , $template_mtime=null )
	{
		if ( is_null($template_mtime) )
			$template_mtime = $this->FetchDatetime($name, $type);
 
		if ( $template_mtime === false )
			return false;
 
		if ( !file_exists(self::CACHE_DIR .'/'. $type .'_'. $name .'.php') )
			return false;
 
		if ( false === ($cached_mtime = filemtime(self::CACHE_DIR .'/'. $type .'_'. $name .'.php')) )
			return false;
 
		return ($cached_mtime >= $template_mtime);
	}
 
	/**
	* Compile a template
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $contents should contain the compiled contents of the file
	* @param string $type the resource type - defaults to 'file'
	* @param boolean $is_compiled if true, $contents need to contain the compiled contents, if false, $contents should contain the raw-contents (uncompiled)
	* @return boolean
	*/
	protected function SaveCompiledTemplate( &$name , $contents , $type='file' , $is_compiled=true )
	{
		if ( !is_dir(self::COMPILE_DIR) )
			return false;
 
		$compiled = $is_compiled ? $contents : $this->Compile($contents);
		return (bool)file_put_contents(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php', $compiled);
	}
 
	/**
	* Cache a template
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $contents should contain the parsed contents of the file
	* @param string $type the resource type - defaults to 'file'
	* @return boolean
	*/
	protected function SaveCachedTemplate( &$name , $contents , $type='file' )
	{
		if ( !is_dir(self::CACHE_DIR) )
			return false;
 
		return (bool)file_put_contents(self::CACHE_DIR .'/'. $type .'_'. $name .'.php', $contents);
	}
 
	/**
	* Add / Register a resource type with this Template instance
	* 
	* @param string $name the name of the resource type
	* @param callback $content_callback the function used to fetch the content
	* @param callback $datetime_callback the function used to fetch the last-modified time
	* @return boolean
	*/
	public function AddResourceType( $name , $content_callback , $datetime_callback )
	{
		if ( !is_callable($content_callback) )
		{
			throw new Exception('Your content callback function does not appear to be valid for "'. $name .'".');
			return false;
		}
		if ( !is_callable($datetime_callback) )
		{
			throw new Exception('Your last-modified callback function does not appear to be valid for "'. $name .'".');
			return false;
		}
		$this->resource_types[ $name ] = array($content_callback, $datetime_callback);
		return true;
	}
 
	/**
	* Remove a registered resource type by name
	* 
	* @param string $name the resource type name you wish to remove
	*/
	public function RemoveResourceType( $name )
	{
		if ( isset($this->resource_types[$name]) )
			unset($this->resource_types[$name]);
	}
 
	/**
	* Retrieve a registered resource type
	* 
	* @param mixed $name if null or not provided, all resource types are returned
	* @return mixed array or (bool)false
	*/
	public function GetResourceType( $name=null )
	{
		if ( is_null($name) )
			return $this->resource_types;
		elseif ( isset($this->resource_types[$name]) )
			return $this->resource_types[$name];
		else
			return false;
	}
 
 	/**
 	* Assign a variable to the template engine so it can be parsed
 	* 
 	* @param string $name the variable name
 	* @param mixed $value
 	*/
	public function AssignVar( $name , $value )
	{
		$this->variables[$name] = $value;
	}
 
 	/**
 	* Retrieve the value of an assigned variable
 	* 
 	* @param mixed $name the variable name - if this is not provided or is null, all the variables are returned as an associative array
 	* @return mixed
 	*/
	public function GetVar( $name=null )
	{
		if ( is_null($name) )
			return $this->variables;
		elseif ( isset($this->variables[$name]) )
			return $this->variables[$name];
		else
			return false;
	}
 
 	/**
 	* Remove an assigned variable
 	* 
 	* @param string $name the variable name
 	*/
	public function RemoveVar( $name )
	{
		if ( isset($this->variables[$name]) )
			unset($this->variables[$name]);
	}
 
 	/**
 	* Takes template-contents and returns contents with the necessary PHP code to execute everything, if-statements and variables
 	* 
 	* @param string $str the raw template contents
 	* @return string
 	*/
	public function Compile( $str )
	{
		// replace assigned variables first
		$search = array();
		$replace = array();
		foreach ( $this->variables as $key => $value )
		{
			$search[] = '{$'. $key .'}';
			$replace[] = '<?php echo $this->GetVar("'. addslashes($key) .'");?>';
		}
		$str = str_replace($search, $replace, $str);
 
		// if we're not parsing control structures, there's no need to continue, return the parsed str
		if ( !$this->parse_control_structures )
			return $str;
 
		// replace control-structures, predefined blocks and tags (break, continue), other variables that were not assigned before-hand, and other misc. functions
		$replaced = preg_replace(
			array(
				// find PHP blocks
				'/\{php\}(.*?)\{\/php\}/s' ,
 
				// find loops and loop-controls like continue and break
				'/\{(for|foreach|while)\s([^\}]+)\}(.*?)\{\/(for|foreach|while)\s*\}/s' ,
				'/\{dowhile\s([^\}]+)\}(.*?)\{\/dowhile\s*\}/s' ,
				'/\{break\s*\}/' ,
				'/\{continue\s*\}/' ,
 
				// find if-elseif-else and endif statements
				'/\{if\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{elseif[^\}]+\}|\{else\s*\})/s' ,
				'/\{elseif\s([^\}]+)\}(.*?)(\{\/if\s*\}|\{else\s*\})/s' ,
				'/\{else\s*\}(.*?)(\{\/if\s*\})/s' ,
				'/\{\/if\s*\}/' ,
 
				// find variables
				'/\{\$([^\}|\:]+)\}/' ,
 
				// find php functions {func_name arg, list} - {var_dump array(1,2,3)}
				'/\{([^\s\}$]+)\s?(.*?)\}/'
			) ,
			array(
				// put PHP code inside PHP tags
				'<?php \1 ?>' ,
 
				// replace loop template syntax with PHP template syntax
				'<?php \1(\2):?> \3 <?php end\1;?>' ,
				'<?php do{?> \2 <?php }while(\1);?>' ,
 
				// replace break and continue
				'<?php break;?>' ,
				'<?php continue;?>' ,
 
				// replace if statments
				'<?php if (\1):?>\2\3' ,
				'<?php elseif (\1):?>\2\3' ,
				'<?php else:?>\1\2' ,
				'<?php endif;?>' ,
 
				// replace variables, but allow vars that have not been assigned via AssignVar() so we use isset() to check
				'<?php echo (isset($\1)?$\1:$this->GetVar("\1"));?>' ,
 
				// replace PHP functions - {var_dump array(1,2,3)} becomes var_dump(array(1,2,3));
				'<?php \1(\2);?>'
			) ,
			$str
		);
 
		// return the compiled data
		return $replaced ? $replaced : $str;
	}
}

View: Source

VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: 0 (from 0 votes)

Building a Custom PHP Template Engine, Part 3: Template Resource Types

September 20th, 2010 No comments

In our last exercise, Building a Custom PHP Template Engine, Part 2: Caching and Compiling, we discussed how to cache and compile template files. Today we will learn how to expand our template engine to support caching and compiling of various resource types. A resource type could be worded as “template type”, as it is the type of template we are working with. An example of two different template resource types would include files and database content. With template files, the content is stored in a file on the server, and so we have a consistent way to retrieve the content and check and see when the file was modified. If the file is modified after a cached or compiled version has been created, we re-cache or re-compile the template. With database content, however, it’s not that simple.

Databases have their own schema, or design. We have no way of knowing how somebody will keep track of last-modified dates for the content it stores, nor will we have a way of knowing how they store the content. Since we will not know the database design at all, we need to provide a way to let developers give us the content and the last-modified dates for each different resource type they want to use. To achieve this, we need to modifiy our Template class to allow developers to register resource types. Upon registering a resource type, a callback function should be assigned for each procedure: fetching content and fetching the modified dates. A callback function is a reference to a function which is stored and called at a later time. When we need to fetch the content or the modified dates, we will have to use call_user_func(). This allows us to execute a user-defined function.

Regardless of the resource type, it’s easiest to handle the caching and compiling in a file, as our previous Template class already does. Because content may have the same names accross different resource types, we should save the cached and compiled files with the resource type preceding the name. If the resource type is named, “db”, and the content being fetched is called “contact”, then the file it caches and compiles in would be named “db_contact.php”. If the resource type is a file and the file being fetching is called “contact.tpl”, then the file it caches and compiles in would be named “file_contact.tpl.php”. It’s also common to use the same template files for different session types, such as an admin session and a visitor session. In this scenario, the resource type would be “admin” and “visitor” or whatever you choose to name it, and that way, the content doesn’t conflict between session types if it’s loaded from a cached or compiled version of the file.

With this new feature in place, when fetching a template, the resource type name would need to be passed along with the content name or filename. Of course, we can default the resource type to “file”, so that when fetching a template file, the resource type is not needed. Our new method would be accessed like, $template->Fetch(‘contact’, ‘db’); Before fetching the template, however, we would need to define the resource type: $template->AddResourceType(“db”, ‘content_callback_function’, ‘modifed_date_callback_function’); If we try to fetch content from an undefined resource type, an error should be issued. We should also provide controls for removing resource types after they are added, and for fetching callback functions for any given resource type, after they have been added. We can save the resource types in a private array, in our Template class. It would also be really nice and convenient to let the resource types be saved in files, in a pre-determined location, so that every instantiated Template class would automatically have access to those without any additional work.

The Class

Here’s our modified Template class. Surprisingly, not much was changed. Our methods for compiling and caching pretty much stay the same, they just have an extra variable to worry about, the resource type. And then, slight changes were made to the way the templates get fetched in our Fetch method. But all in all, the real changes to our class are for the new code necessary to register and un-register the template resource types. A big block of code is also attributed for pre-defined resource types, which live in files on the server, to avoid extra work each time we wish to use them

<?php
 
class Template
{
	/**
	* TEMPLATE_DIR is a constant which holds the path to the directory where the original template files are stored
	*
	* @var string path to template directory
	**/
	const TEMPLATE_DIR = 'TemplateEngine/templates';
 
	/**
	* COMPILE_DIR is a constant which holds the path to the directory where the compiled template files are stored
	*
	* @var string path to compiled directory
	**/
	const COMPILE_DIR = 'TemplateEngine/templates_compiled';
 
	/**
	* CACHE_DIR is a constant which holds the path to the directory where the cached template files are stored
	*
	* @var string path to cached directory
	**/
	const CACHE_DIR = 'TemplateEngine/templates_cached';
 
	/**
	* RESOURCE_DIR is a constant which holds the path to the directory where resource types may be declared in files
	* 
	* @var string path to the directory where pre-defined template resource types are stored
	*/
	const RESOURCE_DIR = 'TemplateEngine/template_resource_types';
 
	/**
	* This stores the resource types that have been loaded from files in the pre-determined location (self::RESOURCE_DIR). 
	* This is here so we don't have to look for them on each instantiation, only the first of each page request
	* 
	* @var array
	*/
	private static $LoadedResourceTypes = array();
 
	/**
	* This is an associative array which holds the variable names (keys) and the values to parse in the template file
	*
	* @var array - associative array
	**/
	private $variables = array();
 
	/**
	* Associative array of resource-types that have been registered with this class
	* 
	* @var array - array('type_name'=>array('content_callback_function','datestamp_callback_function'))
	*/
	private $resource_types = array();
 
	/**
	* This is a boolean indicating whether or not this class should compile templates or use compiled templates when fetched - true by default
	*
	* @var boolean true by default
	**/
	public $compiling = true;
 
	/**
	* This is a boolean indicating whether or not this class should cache templates or use cached templates when fetched - false by default
	*
	* @var boolean false by default
	**/
	public $caching = false;
 
	/**
	* This is a boolean indicating whether or not this class should re-compile the template if it is already compiled, regardless of its datestamps
	*
	* @var boolean
	**/
	public $recompile = false;
 
	/**
	* This is a boolean indicating whether or not this class should re-cache the template if it is already cached, regardless of its datestamps
	* 
	* @var boolean
	*/
	public $recache = false;
 
	/**
	* Our constructor method - this will load resource types from files and handle any other procedures we may need 
	* 
	*/
	public function __construct()
	{
		// load pre-defined resource types from files
		$this->LoadResourceTypes();
 
		// register our default 'file' resource type
		$this->AddResourceType('file', array($this, 'FetchFileContent'), array($this, 'FetchFileDatetime'));
	}
 
	/**
	* This loads resource types from files in self::RESOURCE_DIR
	* 
	* @return boolean
	*/
	private function LoadResourceTypes()
	{
		// only read the directory once per request
		if ( !empty(self::$LoadedResourceTypes) )
		{
			$this->resource_types = self::$LoadedResourceTypes;
			return true;
		}
 
		// make sure we have a valid directory
		if ( !is_dir(self::RESOURCE_DIR) )
			return false;
 
		// read all files in the directory
		if ( $dir = opendir(self::RESOURCE_DIR) )
		{
			while ( false !== ($file = readdir($dir)) )
			{
				if ( !is_dir(self::RESOURCE_DIR .'/'. $file) )
				{
					// use pathinfo to retrieve the filename without the extension - introduced in PHP 5.2.0
					$filename = pathinfo($file, PATHINFO_FILENAME);
 
					/**
					* execute the file to retrieve the callback functions
					* the function names must follow a specific naming convention
					* 
					* content functions - function filename_template_content( $template_name ) { } - should return the template content or (bool)false
					* datetime functions - function filename_template_datetime( $template_name ) { } - should return the last-modified time as a unix-timestamp or (bool)false
					*/
 
					// use output buffering so nothing outputs to the browser
					ob_start();
					include self::RESOURCE_DIR .'/'. $file;
					ob_end_clean();
 
					// make sure the callback functions exist
					if ( !is_callable($filename .'_template_content') )
					{
						throw new Exception($file .' does not have a valid callback function for fetching content.');
						continue;
					}
 
					if ( !is_callable($filename .'_template_datetime') )
					{
						throw new Exception($file .' does not have a valid callback function for fetching a last-modified date.');
						continue;
					}
 
					// save the resource type
					$this->resource_types[ $filename ] = self::$LoadedResourceTypes[ $filename ] = array($filename .'_template_content', $filename .'_template_datetime');
				}
			}
			closedir($dir);
		}
 
		return true;
	}
 
	/**
	* Display a fetched template - uses 'print'
	* 
	* @param string $name the name of the template to display
	* @param string $type the resource type we are using to fetch the template - defaults to 'file'
	*/
	public function Display( $name , $type='file' )
	{
		$contents = $this->Fetch($name, $type);
		print $contents;
	}
 
	/**
	* Fetch a template
	* 
	* @param string $name the filename of the template to fetch
	* @param string $type the resource type - defaults to 'file'
	* @return mixed (bool)false or string contents of the parsed file
	*/
	public function Fetch( $name , $type='file' )
	{
		// fetch the last-modified time of the template here so it's not done multiple times
		$template_mtime = $this->FetchDatetime($name, $type);
 
		if ( $this->caching && !$this->recache && $this->IsCached($name, $type, $template_mtime) )
			return file_get_contents(self::CACHE_DIR .'/'. $type .'_'. $name .'.php');
 
		if ( $this->compiling && !$this->recompile && $this->IsCompiled($name, $type, $template_mtime) )
		{
			ob_start();
			include self::COMPILE_DIR .'/'. $type .'_'. $name .'.php';
			return ob_get_clean();
		}
 
		$contents = $this->FetchContent($name, $type);
		if ( !$contents )
		{
			throw new Exception('Contents for '. $name .' could not be fetched.');
			return false;
		}
 
		$parsed = $this->Parse($contents);
 
		if ( $this->compiling )
			$this->CompileTemplate($name, $contents, $type);
 
		if ( $this->caching )
			$this->CacheTemplate($name, $parsed, $type, true);
 
		return $parsed;
	}
 
	/**
	* Retrieve the content for a registered resource type
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @return mixed the file contents or (bool)false
	*/
	public function FetchContent( &$name , $type )
	{
		if ( !isset($this->resource_types[$type]) )
			return false;
 
		$content = call_user_func($this->resource_types[$type][0], $name);
		return $content ? $content : false;
	}
 
	/**
	* Retrieve the last-modified unix-timestamp for a registered resource type
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @return mixed (int)last-modified as a unix-timestamp or (bool)false
	*/
	public function FetchDatetime( &$name , $type )
	{
		if ( !isset($this->resource_types[$type]) )
			return false;
 
		$datetime = call_user_func($this->resource_types[$type][1], $name);
		return $datetime ? $datetime : false;
	}
 
	/**
	* Retrieve the content for a 'file' template type - this is for our default resource type
	* 
	* @param string $name the template name or file-name - this is by reference, because we may change the file name with a default file extension
	* @return mixed the file contents or (bool)false
	*/
	public function FetchFileContent( &$name )
	{
		$name = trim($name, '/');
		if ( !file_exists(self::TEMPLATE_DIR .'/'. $name) )
		{
			$ext = pathinfo($name, PATHINFO_EXTENSION);
			if ( empty($ext) )
			{
				if ( !file_exists(self::TEMPLATE_DIR .'/'. $name .'.tpl') )
					return false;
 
				$name = $name .'.tpl';
			}
		}
		return file_get_contents(self::TEMPLATE_DIR .'/'. $name);
	}
 
	/**
	* Retrieve the last-modified date for a 'file' template type - this is for our default resource type
	* 
	* @param string $name the template name or file-name - this is by reference, because we may change the file name with a default file extension
	* @return mixed the last-modified date as a unix-timestamp or (bool)false
	*/
	public function FetchFileDatetime( &$name )
	{
		$name = trim($name, '/');
		if ( !file_exists(self::TEMPLATE_DIR .'/'. $name) )
		{
			$ext = pathinfo($name, PATHINFO_EXTENSION);
			if ( empty($ext) )
			{
				if ( !file_exists(self::TEMPLATE_DIR .'/'. $name .'.tpl') )
					return false;
 
				$name = $name .'.tpl';
			}
		}
		return filemtime(self::TEMPLATE_DIR .'/'. $name);
	}
 
	/**
	* See if a template-file is compiled already
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $type the resource type - defaults to 'file'
	* @param mixed $template_mtime should be an integer unix-timestamp of the last-modified time or null - if null, it will be fetched in this function
	* @return boolean
	*/
	public function IsCompiled( &$name , $type='file' , $template_mtime=null )
	{
		if ( is_null($template_mtime) )
			$template_mtime = $this->FetchDatetime($name, $type);
 
		if ( $template_mtime === false )
			return false;
 
		if ( !file_exists(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php') )
			return false;
 
		if ( false === ($compiled_mtime = filemtime(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php')) )
			return false;
 
		return ($compiled_mtime >= $template_mtime);
	}
 
	/**
	* See if a template is cached already
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $type the resource type - defaults to 'file'
	* @param mixed $template_mtime should be an integer unix-timestamp of the last-modified time or null - if null, it will be fetched in this function
	* @return boolean
	*/
	public function IsCached( &$name , $type='file' , $template_mtime=null )
	{
		if ( is_null($template_mtime) )
			$template_mtime = $this->FetchDatetime($name, $type);
 
		if ( $template_mtime === false )
			return false;
 
		if ( !file_exists(self::CACHE_DIR .'/'. $type .'_'. $name .'.php') )
			return false;
 
		if ( false === ($cached_mtime = filemtime(self::CACHE_DIR .'/'. $type .'_'. $name .'.php')) )
			return false;
 
		return ($cached_mtime >= $template_mtime);
	}
 
	/**
	* Compile a template
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $contents should contain the raw contents of the file
	* @param string $type the resource type - defaults to 'file'
	* @return boolean
	*/
	protected function CompileTemplate( &$name , $contents , $type='file' )
	{
		if ( !is_dir(self::COMPILE_DIR) )
			return false;
 
		$compiled = $this->Parse($contents, true);
		return (bool)file_put_contents(self::COMPILE_DIR .'/'. $type .'_'. $name .'.php', $compiled);
	}
 
	/**
	* Cache a template
	* 
	* @param string $name the template name - this is by reference, because the callback may alter it slightly
	* @param string $contents should contain the cached contents of the file
	* @param string $type the resource type - defaults to 'file'
	* @param boolean $is_parsed true if the contents has already been parsed, false otherwise
	* @return boolean
	*/
	protected function CacheTemplate( &$name , $contents , $type='file' , $is_parsed=false )
	{
		if ( !is_dir(self::CACHE_DIR) )
			return false;
 
		$cached = $is_parsed ? $contents : $this->Parse($contents);
		return (bool)file_put_contents(self::CACHE_DIR .'/'. $type .'_'. $name .'.php', $cached);
	}
 
	/**
	* Add / Register a resource type with this Template instance
	* 
	* @param string $name the name of the resource type
	* @param callback $content_callback the function used to fetch the content
	* @param callback $datetime_callback the function used to fetch the last-modified time
	* @return boolean
	*/
	public function AddResourceType( $name , $content_callback , $datetime_callback )
	{
		if ( !is_callable($content_callback) )
		{
			throw new Exception('Your content callback function does not appear to be valid for "'. $name .'".');
			return false;
		}
		if ( !is_callable($datetime_callback) )
		{
			throw new Exception('Your last-modified callback function does not appear to be valid for "'. $name .'".');
			return false;
		}
		$this->resource_types[ $name ] = array($content_callback, $datetime_callback);
		return true;
	}
 
	/**
	* Remove a registered resource type by name
	* 
	* @param string $name the resource type name you wish to remove
	*/
	public function RemoveResourceType( $name )
	{
		if ( isset($this->resource_types[$name]) )
			unset($this->resource_types[$name]);
	}
 
	/**
	* Retrieve a registered resource type
	* 
	* @param mixed $name if null or not provided, all resource types are returned
	* @return mixed array or (bool)false
	*/
	public function GetResourceType( $name=null )
	{
		if ( is_null($name) )
			return $this->resource_types;
		elseif ( isset($this->resource_types[$name]) )
			return $this->resource_types[$name];
		else
			return false;
	}
 
 	/**
 	* Assign a variable to the template engine so it can be parsed
 	* 
 	* @param string $name the variable name
 	* @param mixed $value
 	*/
	public function AssignVar( $name , $value )
	{
		$this->variables[$name] = $value;
	}
 
 	/**
 	* Retrieve the value of an assigned variable
 	* 
 	* @param mixed $name the variable name - if this is not provided or is null, all the variables are returned as an associative array
 	* @return mixed
 	*/
	public function GetVar( $name=null )
	{
		if ( is_null($name) )
			return $this->variables;
		elseif ( isset($this->variables[$name]) )
			return $this->variables[$name];
		else
			return false;
	}
 
 	/**
 	* Remove an assigned variable
 	* 
 	* @param string $name the variable name
 	*/
	public function RemoveVar( $name )
	{
		if ( isset($this->variables[$name]) )
			unset($this->variables[$name]);
	}
 
 	/**
 	* Takes template-contents and returns those contents with variables replaced with values
 	* 
 	* @param string $str
 	* @param boolean $compile true if you wish to return 'compiled code' instead of parsed code - compiled code has template syntax changed into php syntax
 	* @return string
 	*/
	public function Parse( $str , $compile=false )
	{
		$search = array();
		$replace = array();
		foreach ( $this->variables as $name => $value )
		{
			$search[] = '{$'. $name .'}';
 
			if ( $compile )
				$replace[] = '<?php echo $this->GetVar("'. addslashes($name) .'");?>';
			else
				$replace[] = $value;
		}
		return str_replace($search, $replace, $str);
	}
}

View: Source

Pre-Defined Resource Types

Pre-defined resource types live within their own files, in a location determined by the template class. The template class should have a constant which stores the path to where these files reside. In the constructor of our Template class, we should call a method which reads the files in this directory and registers each file as a resource type. The file name is the resource type name. This is a little tricky, though. The class actually has to execute the files in order to get a reference to callback functions, we can use include() for that. Just like we do when compiling, we need to use output buffering so that when the file is executed, no text gets sent to the browser. We also need to come up with a pre-determined function naming scheme, which each of these resource types would need to follow. If we don’t, we will have no idea which functions to use as the callbacks. Let’s use the naming scheme <type>_template_content and <type>_template_datetime, for function names, where <type> is the resource type which also matches the file name. So, if I were to store a custom resource type in db.php, my callback functions would be db_template_content and db_template_datetime. Each function needs to accept the name of the template we are fetching as an argument, and it needs to return the data we expect. For the datetime callback, we need the last-modified date of the template returned in a unix-timestamp, and for the content callback, we need the raw contents of the template returned as a string. If either of these values cannot be found, a boolean value of false should be returned. If our class receives a value of false, we should issue an error, or better, throw an Exception.

Now, let’s pretend you have a Content Management System (CMS), and in that, you allow administrators to create and modify content for pages in the website. They give each content a name and they use some rich-text editor, or wysiwyg, to write the content. When the content is saved, it goes into a database table. Let’s pretend the table is named ‘templates’. This table also keeps track of the last-modified date for each content. The columns in this database table would be, `name`, `content`, `last_modified`, and then whatever else you might like to use in your database design, such as an id of the user who created it, perhaps. So, you have this database table and now you need to use the Template class to fetch, compile, and cache content. You know this is going to be something you do over and over, and you don’t want to have to worry about registring a resource type each time you want to use it. That’s what pre-defined resource types are for. You define the resource type in a file. Let’s call the file, ‘cms.php’, which means our resource type is ‘cms’. When you wish to fetch a template from this new database table, you would use $template->Fetch(‘template_name’, ‘cms’);

Let’s take a look at how you would write that file, cms.php. My class uses ‘TemplateEngines/template_resource_types’ as the directory to store pre-defined resource types, so that’s where this file is saved for me. If you wish to modify this location, you can do so, but the file needs to be saved wherever you specify. You specify this in the class constant, RESOURCE_DIR.

<?php
 
function cms_template_content( $name )
{
	$rs = mysql_query('select content from templates where name = "'. mysql_real_escape_string($name) .'"');
	if ( !$rs || !mysql_num_rows($rs) )
		return false;
 
	list($content) = mysql_fetch_array($rs, MYSQL_NUM);
	return $content;	
}
 
function cms_template_datetime( $name )
{
	$rs = mysql_query('select last_modified from templates where name = "'. mysql_real_escape_string($name) .'"');
 
	if ( !$rs || !mysql_num_rows($rs) )
		return false;
 
	list($last_modified) = mysql_fetch_array($rs, MYSQL_NUM);
	return $last_modified;
}

Coincidentally, if you would not like to use pre-defined templates, you can simply create a new class extension for the same behavior. The only difference is, you would have to use that class extension each time you want access to this resource type:

<?php
 
class CMSTemplate extends Template
{
	public function __construct()
	{
		parent::__construct();
 
		$this->AddResourceType(
			'cms',
			function($name)
			{
				$rs = mysql_query('select content from templates where name = "'. mysql_real_escape_string($name) .'"');
 
				if ( !$rs || !mysql_num_rows($rs) )
					return false;
 
				list($content) = mysql_fetch_array($rs, MYSQL_NUM);
				return $content;
			},
			function($name)
			{
				$rs = mysql_query('select last_modified from templates where name = "'. mysql_real_escape_string($name) .'"');
 
				if ( !$rs || !mysql_num_rows($rs) )
					return false;
 
				list($last_modified) = mysql_fetch_array($rs, MYSQL_NUM);
				return $last_modified;
			}
		);
	}
}

Both are the same, except that every Template object you ever use would have access to the resource type if it was saved in the file, and only objects which used CMSTemplate would have access to it, if you went the other way. Now, it’s not necessary to create a new class extension to simply use the AddResourceType method. You can add a resource type to any object after its instantiated. This example is only here to demonstrate how you can do it only once, and use it over and over.

Usage Example

Here’s a usage example. This example follows the previous examples in the last related articles, except it displays a template from our new ‘cms’ resource type. We can assume the template is the exact same as in the previous articles, the only difference is that its stored in a database table now, instead of a file on the server.

<?php
 
// include and instantiate the template class
require_once 'Template.php';
$template = new Template;
 
// get contact info from database
require_once 'db.php';
$rs = mysql_query('select address, phone, email from company', $dblink);
if ( !$rs || mysql_errno($rs) > 0 || mysql_num_rows($rs) <= 0 )
{
	// if we cannot find data, display a message and assign empty values
	echo 'Error while retrieving company contact information';
	$template->AssignVar('address', 'N/A');
	$template->AssignVar('phone', 'N/A');
	$template->AssignVar('email', 'N/A');
} else {
	// if we have data, assign it
	$row = mysql_fetch_array($rs, MYSQL_ASSOC);
	$template->AssignVar('address', $row['address']);
	$template->AssignVar('phone', $row['phone']);
	$template->AssignVar('email', $row['email']);
}
 
// Tell the object to display a 'cms' template resource
$template->Display('contact', 'cms');
 
?>
VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: 0 (from 0 votes)

Building a Custom PHP Template Engine, Part 2: Caching and Compiling

September 19th, 2010 No comments

Earlier, we discussed the approach for a basic template engine, in Building A Custom PHP Template Engine, Part 1. Today we are going to take a look at how we can cache and compile our templates to speed up processing times. What that means, exactly, is that when a template is fetched, we will store the contents in another file, so that the next time we fetch the template, PHP does not have to find and replace the values. There is a difference between caching and compiling, though.

Caching is as explained above. When a template file is fetched, it gets parsed, which basically means the variable syntax {$varname} is replaced by assigned values: $template->AssignVar(‘varname’, ‘value’); so, ‘{$varname}’ would be replaced with ‘value’. If we are caching the template, the returned contents are written to a file just as they are, with variables replaced. The next time the template engine needs to fetch this template file, it looks for a cached version, and if it finds one, it reads the cached-file’s contents and returns that data.

Compiling is a little different. When compiling the template, after its fetched, it is parsed with PHP syntax instead of values. What that means is, instead of {$varname} being replaced by its value, it gets replaced with the PHP syntax necessary to print the value: <?php echo $varname;?>. The next time the template file is fetched, if there is a compiled version available, it uses output buffering to execute the file within the scope of the Template class, and saves its contents to a variable which we then print to the browser. Because the file is executed within the scope of the Template class, we can use the available method, ‘GetVar’. So, instead of <?php echo $varname;?>, it’s <?php echo $this->GetVar(‘varname’);?>. In this context, ‘$this’ refers to the Template instance which is fetching the template file. The compiled template file is executed by using PHP’s include() function: ob_start(); include ‘filename.php’; $contents = ob_get_clean(); and this is done inside the method. This allows PHP to execute the code directly, and it saves PHP the hassle of searching and replacing variables next time the template is fetched.

Whether caching or compiling, it’s only done once every time the file is modified, or after the file is created and fetched for the first time. It would be pointless to cache and compile every time. Our code needs to check the modification time of the file, filemtime(), to see if the template file has been modified since the cached or compiled versions have been created. If true, cache or compile the template file again, if false, use the cached or compiled versions.

You wouldn’t cache and compile the same file though. If the file is cached, it reads the file from cache before it has a chance to see if the file has been compiled. Also, if the file is cached, it is cached with the variable values that were assigned when the caching took place. Each subsequent request for that template file would have those values, regardless of what is assigned using AssignVar each time you fetch the file. Caching is good when you know the variable values aren’t going to change each time the file is displayed. Compiling is good when you know the main content isn’t changing every time the template is fetched, but only the variable values. When compiling, you can assign different values to the variables using AssignVar, and each time the template is requested, the values may hold something else, but PHP doesn’t have to search and replace for them anymore.

This excercise limits itself to files. In Part 3, we will discuss how to cache and compile other template types, such as database content.

The Class

Here is our modified Template class:

<?php
 
class Template
{
	/**
	* TEMPLATE_DIR is a constant which holds the relative path to the directory where the original template files are stored
	*
	* @var string relative path to template directory
	**/
	const TEMPLATE_DIR = 'TemplateEngine/templates';
 
	/**
	* COMPILE_DIR is a constant which holds the relative path to the directory where the compiled template files are stored
	*
	* @var string relative path to compiled directory
	**/
	const COMPILE_DIR = 'TemplateEngine/templates_compiled';
 
	/**
	* CACHE_DIR is a constant which holds the relative path to the directory where the cached template files are stored
	*
	* @var string relative path to cached directory
	**/
	const CACHE_DIR = 'TemplateEngine/templates_cached';
 
	/**
	* This is an associative array which holds the variable names (keys) and the values to parse in the template file
	*
	* @var array - associative array
	**/
	private $variables = array();
 
	/**
	* This is a boolean indicating whether or not this class should compile templates or use compiled templates when fetched - true by default
	*
	* @var boolean true by default
	**/
	public $compiling = true;
 
	/**
	* This is a boolean indicating whether or not this class should cache templates or use cached templates when fetched - false by default
	*
	* @var boolean false by default
	**/
	public $caching = false;
 
	/**
	* This is a boolean indicating whether or not this class should re-compile the template if it is already compiled, regardless of its datestamps
	*
	* @var boolean
	**/
	public $recompile = false;
 
	/**
	* This is a boolean indicating whether or not this class should re-cache the template if it is already cached, regardless of its datestamps
	* 
	* @var boolean
	*/
	public $recache = false;
 
	/**
	* Display a fetched template - uses 'print'
	* 
	* @param string $filename the filename of the template to display
	*/
	public function Display( $filename )
	{
		$contents = $this->Fetch($filename);
		print $contents;
	}
 
	/**
	* Fetch a template
	* 
	* @param string $filename the filename of the template to fetch
	* @return mixed (bool)false or string contents of the parsed file
	*/
	public function Fetch( $filename )
	{
		$filename = trim($filename, '/');
 
		if ( $this->caching && !$this->recache && $this->IsCached($filename) )
			return file_get_contents(self::CACHE_DIR .'/'. $filename .'.php');
 
		if ( $this->compiling && !$this->recompile && $this->IsCompiled($filename) )
		{
			ob_start();
			include self::COMPILE_DIR .'/'. $filename .'.php';
			return ob_get_clean();
		}
 
		if ( file_exists(self::TEMPLATE_DIR .'/'. $filename) )
		{
			$contents = file_get_contents(self::TEMPLATE_DIR .'/'. $filename);
			$parsed = $this->Parse($contents);
 
			if ( $this->compiling )
				$this->CompileTemplate($filename, $contents);
 
			if ( $this->caching )
				$this->CacheTemplate($filename, $parsed, true);
 
			return $parsed;
		}
 
		return null;
	}
 
	/**
	* See if a template-file is compiled already
	* 
	* @param string $filename
	* @return boolean
	*/
	public function IsCompiled( $filename )
	{
		$filename = trim($filename, '/');
 
		if ( !file_exists(self::COMPILE_DIR .'/'. $filename .'.php') )
			return false;
 
		if ( false === ($template_mtime = filemtime(self::TEMPLATE_DIR .'/'. $filename)) )
			return false;
 
		if ( false === ($compiled_mtime = filemtime(self::COMPILE_DIR .'/'. $filename .'.php')) )
			return false;
 
		return ($compiled_mtime >= $template_mtime);
	}
 
	/**
	* See if a template-file is cached already
	* 
	* @param string $filename
	* @return boolean
	*/
	public function IsCached( $filename )
	{
		$filename = trim($filename, '/');
 
		if ( !file_exists(self::CACHE_DIR .'/'. $filename .'.php') )
			return false;
 
		if ( false === ($template_mtime = filemtime(self::TEMPLATE_DIR .'/'. $filename)) )
			return false;
 
		if ( false === ($cached_mtime = filemtime(self::CACHE_DIR .'/'. $filename .'.php')) )
			return false;
 
		return ($cached_mtime >= $template_mtime);
	}
 
	/**
	* Compile a template
	* 
	* @param string $filename
	* @param string $contents should contain the un-parsed contents of the file
	* @return boolean
	*/
	protected function CompileTemplate( $filename , $contents )
	{
		if ( !is_dir(self::COMPILE_DIR) )
			return false;
 
		$compiled = $this->Parse($contents, true);
		return (bool)file_put_contents(self::COMPILE_DIR .'/'. trim($filename, '/') .'.php', $compiled);
	}
 
	/**
	* Cache a template
	* 
	* @param string $filename
	* @param string $contents should contain the contents of the file (parsed or not, see $is_parsed)
	* @param boolean $is_parsed true if the contents have already been parsed, false otherwise
	* @return boolean
	*/
	protected function CacheTemplate( $filename , $contents , $is_parsed=false )
	{
		if ( !is_dir(self::CACHE_DIR) )
			return false;
 
		$cached = $is_parsed ? $contents : $this->Parse($contents);
		return (bool)file_put_contents(self::CACHE_DIR .'/'. trim($filename, '/') .'.php', $cached);
	}
 
 	/**
 	* Assign a variable to the template engine so it can be parsed
 	* 
 	* @param string $name the variable name
 	* @param mixed $value
 	*/
	public function AssignVar( $name , $value )
	{
		$this->variables[$name] = $value;
	}
 
 	/**
 	* Retrieve the value of an assigned variable
 	* 
 	* @param mixed $name the variable name - if this is not provided or is null, all the variables are returned as an associative array
 	* @return mixed
 	*/
	public function GetVar( $name=null )
	{
		if ( is_null($name) )
			return $this->variables;
		elseif ( isset($this->variables[$name]) )
			return $this->variables[$name];
		else
			return false;
	}
 
 	/**
 	* Remove an assigned variable
 	* 
 	* @param string $name the variable name
 	*/
	public function RemoveVar( $name )
	{
		if ( isset($this->variables[$name]) )
			unset($this->variables[$name]);
	}
 
 	/**
 	* Takes template-contents and returns those contents with variables replaced with values
 	* 
 	* @param string $str
 	* @param boolean $compile true if you wish to return 'compiled code' instead of parsed code - compiled code has template syntax changed into php syntax
 	* @return string
 	*/
	public function Parse( $str , $compile=false )
	{
		$search = array();
		$replace = array();
		foreach ( $this->variables as $name => $value )
		{
			$search[] = '{$'. $name .'}';
 
			if ( $compile )
				$replace[] = '<?php echo $this->GetVar("'. addslashes($name) .'");?>';
			else
				$replace[] = $value;
		}
		return str_replace($search, $replace, $str);
	}
}

View: Source

As an example to how we can utilize our new Template class, let’s pretend we have a page on our website which needs to display contact information, just like we did last time. The values displayed in our template engine will come from a database, so the company can choose to change these values whenever they desire, through some Content Management System (CMS). This example would use compiled templates and not caching. For caching, read the class comments and you will see that I have provided a boolean flag which tells the class whether or not we are compiling and whether or not we are caching. Caching is turned off by default, and compiling is turned on. To turn on caching, $template->caching = true; and for compiling $template->compiling = false; any boolean value will work. It’s also good to point at that the class supports a way to force caching or compiling of a template, even if the original file hasn’t been modified. In order to force a re-cache, $template->recache = true; and to force a re-compile, $template->recompile = true; these are false by default.

The new Template class caches, compiles and reads templates in specific directories on the server. Because of this, there are constants in the class which need to contain a path for each. I have chosen to store my templates in TemplateEngine/templates, so this file would be stored at: TemplateEngine/templates/contact.tpl.

?View Code TEMPLATE
<html>
<head>
	<title>Contact Us</title>
</head>
 
<body>
 
<h1>Contact Us</h1>
 
<p>By Phone: <span>{$phone}</span></p>
<p>By Email: <span>{$email}</span></p>
<p>By Mail: <span>{$address}</span></p>
 
</body>
</html>

And here’s a usage example:

<?php
 
// include and instantiate the template class
require_once 'Template.php';
$template = new Template;
 
// get contact info from database
require_once 'db.php';
$rs = mysql_query('select address, phone, email from company', $dblink);
if ( !$rs || mysql_errno($rs) > 0 || mysql_num_rows($rs) <= 0 )
{
	// if we cannot find data, display a message and assign empty values
	echo 'Error while retrieving company contact information';
	$template->AssignVar('address', 'N/A');
	$template->AssignVar('phone', 'N/A');
	$template->AssignVar('email', 'N/A');
} else {
	// if we have data, assign it
	$row = mysql_fetch_array($rs, MYSQL_ASSOC);
	$template->AssignVar('address', $row['address']);
	$template->AssignVar('phone', $row['phone']);
	$template->AssignVar('email', $row['email']);
}
 
// The new Template class has an option to Display the data, which basically calls Fetch and then prints the data which is returned, for lazy folks like myself :)
$template->Display('contact.tpl');
 
?>
VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: +1 (from 1 vote)

Extending Smarty With Block Functions

Smarty is a templating engine for PHP, written in PHP, to help software engineers maintain seperation. Ideally, you don’t want your front-end, beginner-to-intermediate PHP developers meddling around in your framework, so you create what’s known as an MVC, or Model View Control. When implemented correctly, Smarty fits into the View and Control parts of this concept. Smarty’s “view” are really template files with special syntax which allow access to PHP components in a simplified manner; see www.smarty.net for details. Smarty’s “control” are really Smarty-plugins. Smarty-plugins are resources, blocks, and functions; see www.smarty.net for details. In this blog, we are going to focus on Block Function Plugins.

Now, PHP is a template engine from the start, so it does not need Smarty or anything else (templating engines) to accomplish this goal. However, there are applications and uses for a template-style syntax, which may require access to controls within your MVC. If utilized correctly, Smarty can be powerful for these scenarios, while providing some interesting functionality not otherwise easily achievable.

Smarty steps to the plate with Block Functions. Block Functions “enclose a template block and operate on the contents of this block,” so you have {block_name argument=”list”}contents: {$varname}{/block_name}. For simple operations, Block Functions are easy to write, but I’ll tell you, it can get a little confusing when you’re trying to write something more complex. See, the way Smarty implemented this back in the PHP 4 days, is with a while-loop.

  1. Smarty calls the Block Function on the opening tag {block_name …}, with various arguments, some passed by reference, and some by value. Among the variables, you have $content, and $repeat.
  2. Then it uses output buffering, and sticks your template code in the loop with {$variables} and such already parsed by the template engine. PHP executes this code after the Block Function has been called the first time, and stores the output into the output buffer. This gives you time to assign / unasign variables for your block of code, on the first function call.
  3. After the template code is executed, Smarty gathers the contents in the buffer and stores them in the $contents variable and then kills the buffer.
  4. Finally, Smarty calls the Block Function again, with $repeat as (bool)false and $content with the buffer’s content. If you want to loop again, you set $repeat to (bool)true, and it will repeat the process.

When you look at the “compiled” source that Smarty generates, things become more clear. It doesn’t stop there though, Smarty maintains a tag-stack, which is a stack of nested Block Functions, and each one creates a nested while-loop in the compiled code. Damn, it sure can get hairy when you don’t structure things correctly. I’m anxious to see how Smarty 3 will handle this, but until then, I think I did a pretty good job at making sense of things, in my Smarty-Block classes.

The Smarty-Block Class

Smarty’s implementation of this is poor, in my opinion. Although, I suppose if you consider the evolution of PHP, it really wasn’t all that bad. The shortcomings I encountered were in trying to communicate with parent-blocks from within nested-blocks. I wanted to make a nested-block that relied on its parent, and visa-versa much like <object> <param /> </object> does in HTML. So after hours of headache from fantasies about breaking the fingers of all the developers involved in creating this, I set myself on a goal to simplify working with nested blocks, and rid of the redundancies involved in extending Smarty with Block Functions. I chose OOP. In return, I found harmony in a chaotic world.

First, I have a block class, which I call, SmartyBlock, that controls the tag-stack, which I call ‘nest’, and gives you access to block objects at different depths. SmartyBlock maintains the tag-arguments, it let’s you know if you are on the opening tag or closing tag (with no confusion), it tells you your current tag-stack depth, it allows you to access objects at different depths, and it runs the block for you so that you can cleanly seperate your real code and concentrate on what matters. SmartyBlock is an Abstract class, and it requires you to define your own ‘Run()’ method, which is where you can gather information and assign data before each output.

The class has ‘assign_data’, a data member which is protected, but accessible from within extended classes. This should be an associative array. Before the content is processed, this data is assigned to Smarty so that PHP can evaluate it correctly.

The class has ‘repeat’, a boolean data member which is a reference to the variable passed to the block function by Smarty. Setting the value of this variable to (bool)false will cause the block to cease at the end of the current iteration.

The class has ‘content’, a string data member which is a copy of the content that Smarty gave us. This contains the data after it has already been processed by Smarty. If this variable has no data, that means one of two things: on the opening tag, nothing; on the closing tag, the block has no content, as in {block_name}{/block_name}.

<?php
 
/* http://www.smarty.net/manual/en/plugins.block.functions.php */
 
abstract class SmartyBlock
{
	private static $nest = array(null);						// array of SmartyBlock objects at their relative nested-depth
	private static $current_depth = 0; 						// the current depth of this operation - index starts at 1, if the depth is 0, there is no depth
 
	protected $assign_data; 							// data that will be assigned to the template before the next output
	protected $tplvar = 'data'; 							// the template variable name that the assign_data will be assigned under - this is the default, extending classes may change it
 
	public $args;  									// the attributes associated to this tag {tagnname attribute="list"}
	public $content; 								// the content; null on the opening tag call otherwise it's the content nested inside the block after being evaluated by Smarty
	public $template;  								// Smarty template resource
	public $repeat;  								// ::Run will continue to execute while this is true
 
	abstract public function Run(); 						// this executes the block
 
	/**
	* Retrieve the current tag-stack depth
	*
	* @returns int the current depth
	**/
	public static function CurrentDepth()
	{
		return self::$current_depth;
	}
 
	/**
	* Retrieve a SmartyBlock instance at the given depth
	*
	* @param int $idx the depth you wish to retrieve a SmartyBlock object at
	* @returns mixed (bool)false or the SmartyBlock instance at the specified depth
	*/
	public static function GetNest( $idx )
	{
		return self::$nest[$idx]?:false;
	}
 
	/**
	* See if we are on the opening tag call
	*
	* @returns bool
	*/
	public function OpeningTag()
	{
		return $this->repeat && !$this->content;
	}
 
	/**
	* See if we are on the closing tag call
	*
	* @returns bool
	*/
	public function ClosingTag()
	{
		return !$this->OpeningTag();
	}
 
	/**
	* See if an argument was specified in the opening tag
	*
	* @param string $name the name of an argument
	* @returns bool
	*/
	public function HasArg( $name )
	{
		return isset($this->args[$name]);
	}
 
	/**
	* Get an argument that was specified in the opening tag
	*
	* @param string $name the name of an argument
	* @returns mixed (bool)false or the value of the argument, most likely as a string
	*/
	public function GetArg( $name )
	{
		return $this->args[$name]?:false;
	}
 
	/**
	* Invoke the block
	*
	* @param array $args the block arguments as an associative array
	* @param mixed $content the processed block content or null
	* @param Smarty a Smarty template instance (theoretically, the instance that executed the template file, but I've have cases where Smarty creates clones or passes a copy of the object - be warned)
	* @returns string the processed block contents
	*/
	public function __invoke( array $args , $content , &$template , &$repeat )
	{
		// if the content is not set, it means this is the opening-tag function call - set a new depth
		if ( !isset($content) )
			self::$current_depth += 1;
 
		$idx = self::$current_depth;
 
		// if we have a new depth, instantiate
		if ( !is_object(self::$nest[$idx]) )
			self::$nest[$idx] = $this;
 
		// initialize the block data
		self::$nest[$idx]->args = $args;
		self::$nest[$idx]->content = $content;
		self::$nest[$idx]->template = &$template;
		self::$nest[$idx]->repeat = &$repeat;
 
		// run the block
		$ret = self::$nest[$idx]->Run();
 
		// assign data for the next iteration in the block (or the next time smarty calls this function)
		if ( self::$nest[$idx]->assign_data )
			$template->assign(self::$nest[$idx]->tplvar, self::$nest[$idx]->assign_data);
 
		// clear the data after it's assigned
		self::$nest[$idx]->assign_data = null;
 
		// if we are not repeating, remove this instance from the nest and decrement the depth - we are done
		if ( !$repeat )
		{
			array_pop( self::$nest );
			self::$current_depth -= 1;
		}
 
		// assign the output or return the data - whatever Run() gave us should be the evaluated content or void
		if ( isset($args['tplvar']) )
		{
			$template->assign($args['tplvar'], $ret);
			return '';
		} else
			return $ret; 
	}
}

View: Source

Now, if you were doing a simple operation, you might not need this. I have a few block plugins that don’t use it. But if you want anything that you can extend upon, or when you start getting into advanced operations with blocks, such as parent/nest like I had, this will deffinitely save you time and pain.

If you have questions about the class, please feel free to ask in the comments section below.

The Smarty-Block-List Class

In addition to SmartyBlock, I have provided SmartyBlockList, which is also an Abstract class, but this time, it takes care of ‘Run()’ for you. It just needs you to give it a list of data, ie. an array. It achieves this by making you provide a method named ‘GetData()’ inside your class. This method should return an array of associative arrays: array( array(‘blah’=>true’, ‘foo’=>’bar’) , array(‘blah’=>false, ‘foo’=>’shmoo’) ). SmartyBlockList will repeat your template code as many times are there are items in the array, each time giving you access to each associative array by key name. If your ‘GetData()’ returns a result set from a database, your key names would be the names of your table columns.

I hope this serves as an example of how to implement SmartyBlock.

<?php
 
abstract class SmartyBlockList extends SmartyBlock
{
	protected $loaded = false;
	protected $index = 0;
	protected $length = 0;
	protected $data = array();
 
	abstract public function GetData();
 
	public function Run()
	{
		$this->tplvar = 'item';
 
		if ( !$this->loaded )
		{
			// opening-tag call - returns void
			$this->data = $this->GetData();
			$this->loaded = true;
			if ( !is_array($this->data) )
			{
				$this->template->trigger_error(get_class($this).'::GetData() did not return an array - SmartyBlockList fails.', E_USER_WARNING);
				$this->repeat = false;
				return;
			}
			$this->index = 0;
			$this->length = count($this->data);
		} else
			$this->index++;
 
		if ( $this->index > $this->length )
		{
			$this->repeat = false;
			return false;
		}
 
		$this->repeat = true;
		$this->assign_data = $this->data[ $this->index ];
 
		if ( $this->index == 0 )
			$this->assign_data['first'] = 'first';
		elseif ( $this->index == $this->length )
			$this->assign_data['last'] = 'last';
 
		/*
		  if the extending class provides it, $this->Iterate() is called on each Iteration
		*/
 
		if ( is_callable(array($this, 'Iterate')) )
			$this->Iterate();
 
		if ( $this->content )
			return $this->content;
	}
}

View: Source

Implementing The Block

Now, let’s create a List-Block of our own. Something simple that loops through products in a given category. We can store this in a file, per Smarty’s standards (see, http://www.smarty.net/manual/en/plugins.block.functions.php), named as: block.CategoryProducts.php, in our plugins directory.

 
require_once '/path/to/SmartyBlock.php';
require_once '/path/to/SmartyBlockList.php';
 
class CategoryProducts extends SmartyBlockList
{
	public function GetData()
	{
		// make sure we know which category to display products for
		if ( !$this->HasArg('category') )
		{
			$this->template->trigger_error('{CategoryProducts} requires a category argument be supplied as an id or category name.');
			return array();
		}
 
		// using PDO with mysql, assuming you have a proper singleton-interface set up.
		// you can change this to mysql_* if you must or view my other post for an example
		// example of a custom PDO Singleton class: http://www.phpprofessional.us/memcache-session-handler/#dbclass
		$pdo = CustomPDO::GetPDO();
 
		// make sure the category provided exists		
		// allow the html dev to list products by category name or by category id
		$category = $this->GetArg('category');
		if ( is_numeric($category) )
		{
			// by category id
			$category_id = (int)$category;
			$st = $pdo->prepare('select name from categories where id = ?');
			$st->execute( array($category_id) );
			if ( !$st || $st->rowCount() <= 0 )
			{
				$this->template->trigger_error('Category id '. $category .' seems to be invalid. {CategoryProducts} fails.');
				return array();
			}
			list($category_name) = $st->fetch();
		} elseif ( is_scalar($category) ) {
			// by category name
			$st = $pdo->prepare('select id from categories where name = ?');
			$st->execute( array($category) );
			if ( !$st || $st->rowCount() <= 0 )
			{
				$this->template->trigger_error('Category '. $category .' seems to be invalid. {CategoryProducts} fails.');
				return array();
			}
			list($category_id) = $st->fetch();
			$category_name = $category;
		}
 
		$data = array('category_name'=>$category_name, 'category_id'=>$category_id);		
 
		/* From this point down is all the code we really need.  All the code above is sanitation and validation. */
 
		// get the products
		$st = $pdo->prepare('select id , name , description , image , price from products where id in ( select idProduct from product_categories where idCategory = ? )');
		$st->execute( array($category_id) );
		if ( $st && $st->rowCount() > 0 )
			return array_merge($data, $st->fetchAll( PDO::FETCH_ASSOC ));
		else
			return $data;
	}
}
 
// now this is how Smarty likes us to interact with our plugin
// we must make a function
function smarty_block_CategoryProducts( $params , $content , &$template , $repeat )
{
	// our SmartyBlock class takes advantage of PHP 5.3's invoke
 
	$block = new CategoryProducts;
	return $block($params, $content, $template, $repeat);
}

Well, that was easy enough. All of the work was consumed in making sure we have a valid category. The actual list retrieval and return was a sinch. Now, we need to make the View. What we just did was part of the Control. Our View will use the template-style syntax mingled with HTML. We can name this file category_products.tpl, so we can use it whenever we need it.

?View Code SMARTY
<h1>Viewing Products In: {$category|ucwords}</h1>
<table class="product_list">
	<tr class="header">
		<td />
		<th>Name</th>
		<th>Price</th>
		<th>Description</th>
		<th>Buy Now</th>
	</tr>
	{CategoryProducts category=$category}
		<tr class="product">
			<td><img src="http://www.blah.com/products/{$item.image}" alt="{$item.name|htmlentities}" /></td>
			<td>{$item.name|htmlentities}</td>
			<td>${$item.price|round:2}</td>
			<td>{$item.description|htmlentities}</td>
			<td>
				<button onclick="location.href = 'addcart.php?idProduct={$item.id|rawurlencode}'; return false;">Add To Cart</button>
			</td>
		</tr>
	{/CategoryProducts}
</table>

So, in order to use this in our main template, we need the category name. I chose to use a category name with my implementation, but our plugin allows us to give it a name or an id, so if we wanted to change this to {CategoryProducts category=$smarty.get.idCategory}, it would use the value 5 from www.blah.com/products.php?idCategory=5 or other similar URL. For my implementation, we need to have a category name, so that either needs to be defined by PHP before calling the template, or from within the template before the category file is included. I am going to set it from within my template file for the sake of this blog.

?View Code SMARTY
<html>
<head>
	<title>Products</title>
	...
</head>
<body>
	...
 
	<div id="products">
		{assign var="category" value="clothes"}
		{include file="category_products.tpl"}
	</div>
 
	...
</body>
</html>

And then we can use the category products list whenever we want, in any template.

VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: +2 (from 2 votes)

How To Validate Email Addresses

May 23rd, 2010 3 comments

So you have a user-input form on your page, and you want to validate an email address to make sure your subscribers are for real. I can dig it. But where do you begin?

You’ve come to right place for that, I’ll take you from start to finish… assuming you have common knowledge of PHP and HTTP Post Requests.

The Format

The first thing you need to consider is the format of the email address. Email addresses have to follow a specific format or they are not valid. That format is username@host, where ‘host’ is an IP address or Domain Name, and user is the username associated to the user account of the person sending or receing the email. Both parameters may be in various fomats, so it makes validating a little tricky. For instance, you might come accross an email address labeled as ‘john.smith@127.0.0.1′ or ‘john_smith@aol.com’ or ‘jsmith@ukdomain.co.uk’ and the list continues. Thankfully, the folks at Zend have provided an easy one-line solution to stop all the crazy sloppy code that manifests as a result of this. Thus, we have filter_var(), so we can now ignore the temptation for using regular expressions.

The filter_var() function does more than just validate email addresses, though, click the link for more information. Basically, there is a constant which describes the type of filter you intend on running. The constant that we are interested in, is FILTER_VALIDATE_EMAIL. The filter_var() function returns the filtered data or a boolean, false, value if it was unsuccessfull. The usage is as follows:

 
// validate the posted data
$validated = filter_var($_POST['email_address'], FILTER_VALIDATE_EMAIL);
 
// print validate status message
if ( $validated === false )
	echo 'Sorry, but that email address is not in a valid format.';
else
	echo 'Thank you, that email address is in a valid format as '. $validated;

So, now that we have an email address in a valid format, we can decide whether or not we want to validate if this email address really exists and can receive email. There may be cases where you want to avoid this, and we shall touch on that in the next section. But for now, we can move forward knowing that our system will not allow “asdfasdf” or “test” as the value of a user’s email address input. For most applications, that’s all we need.

Does The Email Address Really Exist?

Even though an email address appears to be in a valid format, it may not be valid. There may be a case where the system needs to know if a supplied email address really exists and can receive mail. There are a couple ways to go about this. A common and more accepted approach is to send an email to the address, with a URL, and instructions for the user to click on the URL. The URL, in this case, would be a tracking URL that marks that user’s email address as “valid” in the system, as soon as the user loads it in their browser (ie. clicks on the link). If it’s not a URL, another common approach is to create a “key” that gets sent to the user, and then is required to be inserted, so that the system can finalize validation. If it is possible for you to do so, this is the method that works best.

If you desire (and you may have a valid reason to do so) to check that the email address exists before the user can complete the signup, and without actually sending any emails, there does exist a chance, but it is not possible to know with 100% accuracy.

Theoretically, hosts linked to email addresses are associated with MX Records. MX stands for “mail exchanger record” and, to quote wikipedia, “is a type of resource record in the Domain Name System that specifies a mail server responsible for accepting email messages on behalf of a recipient’s domain and a preference value used to prioritize mail delivery if multiple mail servers are available.” Read more at, http://en.wikipedia.org/wiki/MX_record.

Knowing this, we can check to see if there are any MX Records by using getmxrr($host). If there are none, a boolean, false, value is returned. If there are MX Records, an array of records are returned in order of priority (read more about priority at http://en.wikipedia.org/wiki/MX_record). If there are no MX addresses returned, it’s standard to use the A record. The A record gives you the IP address of a domain. We get the A record by using PHP’s built-in function, dns_get_record($host, DNS_A), which returns either an empty array or an associative array of data fitting your request (see the link for more information).

It’s good to point out that some systems, such as Yahoo, will grey list you for unauthorized access to their SMTP server. Some developers may want to avoid this when dealing with email auth. And similar servers will not let you know whether or not a user account is valid, it will say, “try it and see”, so we need to allow all addresses for those hosts. In the example below, you will notice that any yahoo.com email address will slip through, valid user account or not.

Having said that, on with the code. What you can do is loop through each address we found, MX Records or A Records, and as soon as you find a host you can connect to on port 25, we can assume that the email address submitted belongs to a valid mail server. This is under the assumption that port 25 is the default port and the SMTP server is set up to run on that port; this is not always the case, though. The next step is to do an authorization check on that user account, since a connection has already been established. This is done through SMTP, Simple Mail Transfer Protocol. This usually takes place on port 25, so that’s the port that we use when connecting via fsockopen(). Once a connection is established, send the basic commands neccessary for an email message, but don’t send the data; we end the request after we get a response from the server in regards to the ‘RCPT TO’ command. This command tells the SMTP server which user account this email message is intended for. In this response, will be a response code. We will evaluate the response for specific response codes, and if we receive them, we know that the user given in the email address is not valid: ‘user@host.com’. It’s also good to point out that some SMTP servers still support VRFY which is used for this very same reason. It’s always good to check for that when doing your validation before beginning a message on the server. Some servers avoid supporting VRFY, however, so it can’t be relied upon.

Now, this still doesn’t tell us if the user submitting the form belongs to the email address, but it gives us a better guess at whether or not the email address is, in fact, a valid address. To see if the user belongs to this email address, send an email message with a tracking URL, as explained above.

You can read more about SMTP commands and response codes at http://www.ietf.org/rfc/rfc2821.txt.

Putting It Together

 
function ValidateEmail( $email_address )
{
	// validate the posted data
	$validated = filter_var($_POST['email_address'], FILTER_VALIDATE_EMAIL);
	if ( ($ret = (bool)$validated) )
	{
		// get the username and host
		list($user, $mailhost) = explode('@', $validated);
 
		// find mx records for host
		$mx = getmxrr($mailhost, $hosts);
 
		if ( empty($hosts) )
		{
			// try to get the A record
			foreach ( (array)dns_get_record($host, DNS_A) as $address )
				$hosts[] = $address['ip'];
		}
 
		if ( !empty($hosts) )
		{
			// if we have one or most host, loop through the array and try to connect to them
			foreach ( $hosts as $host )
			{
				// try to establish a connection to the host on port 25 with a 15 second timeout
				if ( false !== ($smtp = @fsockopen($host, 25, $errno, $errstr, 15)) )
				{
					// set a maximum wait time of 15 seconds for all responses from the server
					stream_set_timeout($smtp, 15);
 
					// get responses from the server after connecting
					$smtp_response = fgets($smtp);
 
					// send the HELO command and get the response
					fputs($smtp, "HELO hi\n");
					$smtp_response = fgets($smtp);
 
					// send MAIL FROM command and get the response
					fputs($smtp, "MAIL FROM: <somebody@example.com>\r\n");
					$smtp_response = fgets($smtp);
 
					// tell the server who this email is for - since this is the server the email account belongs to, it can verify if the user is valid
					// if the user is not valid, the server will respond with response codes 550, 551, or 553; see, http://www.ietf.org/rfc/rfc2821.txt
					fputs($smtp, "RCPT TO: <$user@$mailhost>\r\n");
					$smtp_response = fgets($smtp);
 
					// if we have an invalid response code, return false
					$ret = !in_array((int)$smtp_response, array(550,551,553));
 
					// tell the server we are don e
					fputs($smtp, "QUIT\r\n");
 
					// close the connection
					fclose($smtp);
 
					// break the loop as soon as we find 1 connectable host
					break;
				}
			}
		} else
			$ret = false;
	}
	return $ret?$validated:false;
}
 
$validated = ValidateEmail( $_POST['email_address'] );
 
if ( $validated === false )
	echo 'Sorry, but the email address you provided does not appear to be valid.';
else
	echo 'Yes, '. $validated .' appears to be a valid email address.';

Try it yourself!

View: Live Demo; Live Source

VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: +1 (from 1 vote)

Text To Speech (TTS)

February 3rd, 2010 5 comments
Have you ever wanted a text to speech application on your website? Something that could take any piece of text and convert it into an audio file for someone to listen to? Well, buckle up, because we are going to make one together. No really, it’s not that hard. Like always, you just need to have the correct tools.

Prerequisites

After a little searching around on the intarwebs, I found some great Open Source solutions that run from the command line and convert text to audio. The one which seemed to have the most support was Festival, so that’s the one I chose. Click on that link for installation and other info. Festival comes with a ‘text2wave’ utility that takes any text file and converts it into a WAV file, and it does this from a shell command. This is perfect!

But wait, WAV is huge and outdated. What we want is MP3. I’ve done some MP3 encoding in the past and the utility I always use is Lame: Lame ain’t an MP3 Encoder. It’s free and it goes along with our theme, Open Source. To install lame, you need to find the correct package for your distro. If you use Ubuntu, you can read more here: http://packages.ubuntu.com/hardy/lame.

So, either install these on the server, or get your system administrator to do it for you. If you have some shared hosting plan like godaddy offers, sorry, but I’m not sure if you will be able to get these components on the server. You would have to contact their support team for that.

The Easy Part

Assuming you have Lame and Festival installed on your server, we can begin the code. This is the easy part :P

Much like the Random Image script we made, we want to make this accessible for any application in our site. Audio applications usually load a file, so we can just make PHP return an audio file directly. It’s not as hard as it sounds. First we need to make a file that we will use to request the audio. I’m going to call mine, ‘TextToSpeech.php’. You can call yours whatever is appropriate for your application. Place this file where you want, typically somewhere accessible from a web browser. To keep things simple, let’s pretend this file is located at http://www.site.com/TextToSpeech.php.

The file will need to accept input, which would be the text we want converted. A complete request would be: http://www.site.com/TextToSpeech.php?txt=The+text+you+want+converted. Make sure the text is url-escaped if you send it over the URL like that. Since we are limited with the amount of data we send over the URL, we should allow other methods to push the text through.

PHP’s $_REQUEST super-global contains the contents of $_GET, $_POST, and $_COOKIE, so with this, we can allow HTTP Post requests, or cookies. This means that javascript could set a cookie with the text, if you didn’t want to send it over the URL. But remember, cookie’s are limited as well.

Ok, so the first thing we need to do is create a temporary file that contains the text we want to convert. We do this because ‘text2wave’ reads the text from a file.

Next, we need permissions to execute some shell code on the server. We need to execute the ‘text2wave’ shell command to create a WAV file for us.

Third, we need to convert this WAV file into an MP3 file, so that we can stream it from the web server efficiently. To do this, we need to execute the necessary shell command for lame to convert a WAV file into an MP3 file for us.

Also, let’s not forget that HTML applications might use this, and there’s always a possibility that HTML data will be vocalized along with the text, so we need to strip_tags so this doesn’t happen. If you have any additional sanitation you need to do, you should do it in the same place.

The Conversion

<?php
 
// create some temporary files
$dir = sys_get_temp_dir();
$txt = tempnam($dir, 'TXT');
$wav = tempnam($dir, 'WAV');
$mp3 = tempnam($dir, 'MP3');
 
// put the content inside the txt file - strip_tags so HTML applications can use it
$txtdata = strip_tags($_REQUEST['txt']);
file_put_contents($txt, $txtdata);
 
// create the wav
exec('text2wave '. $txt .' -o '. $wav);
 
// create the mp3
exec('lame '. $wav .' '. $mp3);
 
// remove txt and wav, we are done with them
unlink($txt);
unlink($wav);
Finally, we need to read the binary data in the MP3 file and flush it to the web browser with the appropriate content-type header.

The Binary Flush

<?php
 
// open mp3 and read it
if ( $f = fopen($mp3, 'rb') )
{
	// read mp3
	$data = '';
	while ( !feof($f) )
		$data .= fread($f, 8192);
 
	// close mp3 and remove it, we are done with it
	fclose($f);
	unlink($mp3);
 
	// send some http request headers
	header('content-type: audio/mpeg');
	header('content-length: '. strlen($data));
 
	// print the data, flush, and exit the script, we are done
	echo $data;
	flush();
	exit;
}

Putting It Together

<?php
 
// if there is no data to parse, exit
if ( !isset($_REQUEST['txt']) || !is_scalar($_REQUEST['txt']) || strlen(trim($_REQUEST['txt'])) == 0 )
	exit;
 
// create some temporary files
$dir = sys_get_temp_dir();
$txt = tempnam($dir, 'TXT');
$wav = tempnam($dir, 'WAV');
$mp3 = tempnam($dir, 'MP3');
 
// put the content inside the txt file - strip_tags so HTML applications can use it
$txtdata = strip_tags($_REQUEST['txt']);
file_put_contents($txt, $txtdata);
 
// create the wav
exec('text2wave '. $txt .' -o '. $wav);
 
// create the mp3
exec('lame '. $wav .' '. $mp3);
 
// remove txt and wav, we are done with them
unlink($txt);
unlink($wav);
 
// open mp3 and read it
if ( $f = fopen($mp3, 'rb') )
{
	// read mp3
	$data = '';
	while ( !feof($f) )
		$data .= fread($f, 8192);
 
	// close mp3 and remove it, we are done with it
	fclose($f);
	unlink($mp3);
 
	// send some http request headers
	header('content-type: audio/mpeg');
	header('content-length: '. strlen($data));
 
	// print the data, flush, and exit the script, we are done
	echo $data;
	flush();
	exit;
}

The Class

<?php
 
 
class TTS
{
	public static $mp3;
 
	// convert
	public static function Convert( $txtdata )
	{
		if ( strlen(trim($txtdata)) == 0 )
			return false;
 
		// create some temporary files
		$dir = sys_get_temp_dir();
		$txt = tempnam($dir, 'TXT');
		$wav = tempnam($dir, 'WAV');
		$mp3 = tempnam($dir, 'MP3');
 
		// put the content inside the txt file - strip_tags so HTML applications can use it
		file_put_contents($txt, trim(strip_tags($txtdata)));
 
		// create the wav
		exec('text2wave '. $txt .' -o '. $wav);
 
		// create the mp3
		exec('lame '. $wav .' '. $mp3);
 
		// remove txt and wav, we are done with them
		unlink($txt);
		unlink($wav);
 
		// hold on to the mp3 location
		self::$mp3 = $mp3;
 
		return true;
	}
 
	// read
	public static function ReadBinary( $txt=null )
	{
		if ( !self::$mp3 )
		{
			if ( is_null($txt) )
			{
				trigger_error('TTS::GetBinary has no file to read binary data from.  Use TTS::GetBinary("the text to convert")', E_USER_ERROR);
				return null;
			}
			self::Convert($txt);
		}
		// open mp3 and read it
		if ( $f = fopen(self::$mp3, 'rb') )
		{
			// read mp3
			$data = '';
			while ( !feof($f) )
				$data .= fread($f, 8192);
 
			// close mp3 and remove it, we are done with it
			fclose($f);
 
			// clean up - if we created the mp3, remove it
			if ( !is_null($txt) )
				self::DeleteMP3();
 
			// return binary data
			return $data;
		}
		// could not open file - either no permissions or file dosn't exist
		trigger_error('Could not open file: '. self::$mp3, E_USER_ERROR);
		return null;
	}
 
	// stream
	public static function Stream( $txt=null )
	{
		if ( !self::$mp3 )
		{
			if ( is_null($txt) )
			{
				trigger_error('TTS::Stream has nothing to stream.  Use TTS::Stream("the text to stream")', E_USER_ERROR);
				return null;
			}
			self::Convert($txt);
		}
 
		// read binary data, flush and exit
		if ( $data = self::ReadBinary() )
		{
			if ( !is_null($txt) )
				self::DeleteMP3();
 
			// send some http request headers
			header('content-type: audio/mpeg');
			header('content-length: '. strlen($data));
 
			// flush data
			echo $data;
			flush();
			exit;
		} elseif ( !is_null($txt) )
			self::DeleteMP3();
	}
 
	// download
	public static function Download( $txt=null )
	{
		if ( !self::$mp3 )
		{
			if ( is_null($txt) )
			{
				trigger_error('TTS::Download has nothing to stream.  Use TTS::Download("the text to download")', E_USER_ERROR);
				return null;
			}
			self::Convert($txt);
		}
 
		// read binary data, flush and exit
		if ( $data = self::ReadBinary() )
		{
			if ( !is_null($txt) )
				self::DeleteMP3();
 
			// send some http request headers
			header('content-type: audio/mpeg');
			header('content-disposition: attachment; filename="PHPProfessional_TextToSpeech.mp3"');
			header('content-length: '. strlen($data));
 
			// flush data
			echo $data;
			flush();
			exit;
		} elseif ( !is_null($txt) )
			self::DeleteMP3();
	}
 
	// delete the mp3 file
	public static function DeleteMP3()
	{
		if ( !self::$mp3 )
			return;
 
		if ( file_exists(self::$mp3) )
			unlink( self::$mp3 );
 
		self::$mp3 = null;
	}
}

View Live Example; Source; Class Source



** If you have problems with the button, select the checkbox to download the audio file. A dialog box will open asking if you want to download or open the file, select open, and select the application you wish to open it with. This would be whatever music player you like to use.

VN:F [1.9.17_1161]
Rating: 10.0/10 (3 votes cast)
VN:F [1.9.17_1161]
Rating: +3 (from 3 votes)

Data Encryption With Mcrypt

February 2nd, 2010 3 comments

Data encryption is an important part in any application, and it’s often looked at as a difficult task. It’s not, you just need the right tools. To simplify the process, let’s write a class that handles encryption and decryption of data for us.

To quote php.net/mcrypt, Mcrypt supports a “wide variety of block algorithms such as DES, TripleDES, Blowfish (default), 3-WAY, SAFER-SK64, SAFER-SK128, TWOFISH, TEA, RC2 and GOST in CBC, OFB, CFB and ECB cipher modes. Additionally, it supports RC6 and IDEA which are considered “non-free”. CFB/OFB are 8bit by default.”

We will use ARCFOUR for our cipher; Arcfour …”is derived from ‘Alternative RC4′. It is a mathematical clone of RC4, apparently to bypass the IP limitations of the original formulation. Arcfour is usually treated as unencumbered…’ See http://mcrypt.sourceforge.net for more information.

Before you begin, make sure mcrypt is installed on the server. If you’re on Debian, it’s easy: apt-get install mcrypt php5-mcrypt, then restart apache: sudo /etc/init.d/apache2 restart.

There’s not much to this. The main thing is maintaining the encryption key that mcrypt will use in encrypting / decrypting values. Our class will generate this key in a file on the server. It’s important that this file is not accessible from a web browser, and that proper measures are taken to secure this file. If you have /var/www/htdocs/index.html don’t put this file in /var/www/htdocs, it should be in /var/www/ or /var/www/other. If you have /var/www/site/htdocs/index.html put it in /var/www/site, etc.

Our class will need to have a routine for the following tasks:

  • Read the key into a variable
  • Generate the key if it does not exist
  • Encrypt Data
  • Decrypt Data

** This excersize assumes that you have defined PATH_TO_KEY_FILE as the directory path you would like to store the encryption key file in, as a PHP constant. And honestly, I would suggest that you change this name. Its name was chosen for clarity within this post.

Reading The Key File

This is easy, just look for the file in the specified location and if it exists, read it with fopen and fread. We define the key in a private static variable so other applications using the Encryption class don’t have to open and read the file every time they want to access it.

If the file doesn’t exists, pass it off for key generation and let that worry about defining the static $key variable.

if ( is_null(self::$key) )
{
	$key = '';
	$file = PATH_TO_KEY_FILE .'.key';
	if ( file_exists($file) )
	{
		if ( $f = fopen($file, 'rb') )
		{
			while ( !feof($f) ) $key .= fread($f, 8192);
			fclose($f);
			self::$key = $key;
		} else
			die('Data Encryption Failed'); 
	} else
		self::GenerateKey();
}

Generating The Key File

This is a little trickier, but still easy. The challenge is coming up with a good enough value to store in the key file that will work with mcrypt and be something that someone will not be able to guess. For this task, we just need to generate a random string of characters; we’ll make our string 250 characters long. So, our encryption key will consist of 250 random characters.

For the next part, we need to save the key into a clever file name. Let’s obscure the file by placing a ‘.’ in front of the file name, that usually indicates a hidden file. So, our file will be called “.key” with no file extension.

if ( $f = fopen(PATH_TO_KEY_FILE .'.key', 'w') )
{
	$key = '';
	for ($i = 0; $i < 250; $i++) $key .= chr( rand(0, 255) );
	fwrite($f, $key);
	fclose($f);
	self::$key = $key;
} else
	die('Data Encryption Failed');

Encrypting The Data

Encryption is done through mcrypt, we don’t have to do much at all. See the PHP Reference for more information.

This is an example using mcrypt directly, without our class.

$key = 'the encryption key';
$data = 'encrypt this value';
$encrypted = mcrypt_encrypt(MCRYPT_ARCFOUR, $key, $data, MCRYPT_MODE_STREAM);

Decrypting The Data

To decrypt the data, we do the exact same thing, except we pass the function encrypted data.

Let’s put it together…

The Class

This class is so simple, we don’t even need to instantiate it. We will access the class entirely through static members.

<?php
 
define('PATH_TO_KEY_FILE', '/var/www/site/');
 
class Encryption
{
	private static $key = null;
 
	private static function GetKey()
	{
		if ( is_null(self::$key) )
		{
			$key = '';
			$file = PATH_TO_KEY_FILE .'.key';
			if ( file_exists($file) )
			{
				if ( $f = fopen($file, 'rb') )
				{
					while ( !feof($f) ) $key .= fread($f, 8192);
					fclose($f);
					self::$key = $key;
				} else
					die('Data Encryption Failed'); 
			} else 
				self::GenerateKey();
		}
		return self::$key;
	}
 
	private static function GenerateKey()
	{
		if ( $f = fopen(PATH_TO_KEY_FILE .'.key', 'w') )
		{
			$key = '';
			for ($i = 0; $i < 250; $i++) $key .= chr( rand(0, 255) );
			fwrite($f, $key);
			fclose($f);
			self::$key = $key;
		} else
			die('Data Encryption Failed'); 
	}
 
	public static function Encrypt( $data )
	{
		return mcrypt_encrypt(MCRYPT_ARCFOUR, self::GetKey(), $data, MCRYPT_MODE_STREAM);
	}
 
	public static function Decrypt( $data )
	{
		return self::Encrypt($data);
	}
}

Usage Example

<?php
 
require_once 'Encryption.php';
$password = Encryption::Encrypt('the password');
echo 'The encrypted password: '. $password;
echo 'The decrypted password: '. Encryption::Decrypt($password);

Click for a Live Example; Source

VN:F [1.9.17_1161]
Rating: 7.0/10 (2 votes cast)
VN:F [1.9.17_1161]
Rating: +2 (from 2 votes)

Placing A Random Image On Your Site

February 1st, 2010 1 comment

Showing a random image on your page is super easy. I’ll show you how to do it so it requires no inline PHP at all.

First we need to create the file that will fetch the random image. When we do it this way, we can use the script on any page with no additional code. I’m going to call my file ‘random_image.php’, but you can call yours whatever you want if you wish to obscure things a bit.

What we will do is make PHP gather all the images in a directory on the server, and display one randomly. The directory PHP reads from will be passed as an argument by the script, and then sanitized to avoid attacks.

<?php
 
// if there is no request data, exit
if ( !isset($_GET['dir']) )
	exit;
 
// initialize the files array, and sanitize the request
$files = array();
$dir = str_replace(array('/','..'), '', $_GET['dir']) .'/';
 
// if $dir exists within the current working directory, grab files
if ( is_dir($dir) )
{
	if ( $dh = opendir($dir) )
	{
		while ( ($file = readdir($dh)) !== false )
			if ( filetype($dir.$file) == 'file' )
				$files[] = $dir.$file;
 
		closedir($dh);
	}
}
 
// if we didn't find anything, exit
if ( empty($files) )
	exit;
 
// get the random image and display it
$idx = array_rand($files, 1);
if ( $f = fopen($files[$idx], 'r') )
{
	// get the mime-type
	if ( PHP_VERSION >= 5.3 )
	{
		$finfo = finfo_open(FILEINFO_MIME_TYPE);
		header('content-type: '. finfo_file($finfo, $files[$idx]));
		finfo_close($finfo);
	} else {
		$mime = mime_content_type($files[$idx]);
		header('content-type: '. $mime);
	}
 
	// the file size
	header('content-length: '. filesize($files[$idx]));
 
	// display and exit
	fpassthru($f);
	fclose($f);
	exit;
}

Then upload some photos into a sub directory where random_image.php resides. I’m calling my directory ‘camping09′, so the full path would be /var/www/site/random_image.php and /var/www/site/camping09/. Here’s my directory structure.

<img src="random_image.php?dir=camping09" alt="Random Camping Photo" />



Random Camping Photo - Click For New Image

VN:F [1.9.17_1161]
Rating: 9.0/10 (2 votes cast)
VN:F [1.9.17_1161]
Rating: +3 (from 5 votes)

DOMDocument Extension

February 1st, 2010 No comments

Have you ever tried to code a project using DOMDocument, and at some point during the process stop and think, “damn, this is annoying”? I sure have. I find the redundancies in DOMDocument nightmarish. As an escape, I coded a class I like to label as DOM. DOM provides scallable and simplified usage; so simplified, I wish I had it when I code DOM in javascript.

DOM Class; Click to view PHP Source. I’m not all that happy with some of it, so keep us locked because I will eventually rewrite it.

To contrast them, I have written the same task twice; once using DOMDocument, and again using DOM. The task is to create the following in as best XHTML as possible:

<div id="body">
    <p id="my_p" class="class list">this is the data inside of my p tag</p>
    <ul>
        <li>List Item One</li>
        <li>List Item Two</li>
        <li>List Item Three</li>
    </ul>
</div>

Coded with DOMDocument

<?php
 
$d = new DOMDocument;
 
$div = $d->createElement('div');
$div->setAttribute('id', 'body');
 
$p = $d->createElement('p');
$p->setAttribute('id', 'my_p');
$p->setAttribute('class', 'class list');
$p->appendChild( $d->createTextNode('this is the data inside my p tag') );
 
$ul = $d->createElement('ul');
$items = array('List Item One','List Item Two','List Item Four');
foreach ( $items as $item )
{
    $li = $d->createElement('li');
    $li->appendChild( $d->createTextNode($item) );
    $ul->appendChild( $li );
}
 
$div->appendChild( $p );
$div->appendChild( $ul );
 
$d->appendChild( $div );
echo $d->saveHTML();

DOMDocument Results

<div id="body">
<p id="my_p" class="class list">this is the data inside my p tag</p>
<ul>
<li>List Item One</li>
<li>List Item Two</li>
<li>List Item Four</li>
</ul>
</div>

Coded with DOM

$d = new DOM;
$div = $d->div(
    $d->frag(
        $d->p('this is the data inside my p tag', array('id'=>'my_p','class'=>'class list')) ,
        $d->ul( array('List Item One','List Item Two','List Item Three') )
    ) ,
    array('id'=>'body')
);
echo $d->getFragXHTML( $div );

DOM Results

<div id="body">
  <p id="my_p" class="class list">this is the data inside my p tag</p>
  <ul>
    <li>List Item One</li>
    <li>List Item Two</li>
    <li>List Item Three</li>
  </ul>
</div>
VN:F [1.9.17_1161]
Rating: 7.0/10 (1 vote cast)
VN:F [1.9.17_1161]
Rating: 0 (from 0 votes)
Categories: Open Source Tags: , , ,

Building A Custom PHP Template Engine, Part 1

January 31st, 2010 2 comments

Creating a template engine in PHP can be really super easy, and then it can be rather difficult. Just as with anything in the world of programming, it depends on your approach, ie. your logic. Hopefully after reading this post you will have a better understanding of how to approach making your own template engine for your own needs. Let’s go ahead and get started…

First thing’s first, we need to decide exactly what we need. What’s the goal of your template? There are different approaches we can take, such as using PHP’s built in XSLT support, we can use DOMDocument, or we can create something which responds to some kind of {template syntax} that we come up with. For starters, let’s make our own template engine, with our own syntax.

Custom Template Engine

A good place to start is by fabricating a quick HTML document, something that might run with this template engine, so you have an idea of how to continue. Let’s pretend this is contact.html, which should display an address, a phone number, and an email addres.

<html>
<head>
	<title>Contact Us</title>
</head>
 
<body>
 
<h1>Contact Us</h1>
 
<p>By Phone: <span>{$phone}</span></p>
<p>By Email: <span>{$email}</span></p>
<p>By Mail: <span>{$address}</span></p>
 
</body>
</html>

To complete our goal, we need to be able to read any text file and parse the template syntax into something valid. We can do this a number of ways, it really depends on how creative you want to get with your template syntax. For this excersize, we will keep it simple, and only focus on template variables. We mark template variables with a {$ and then the name, followed by a }, so, {$myvar}. We need to write a class that will read text, parse the variables, and return the output.

Now, making the engine look for template syntax is one thing, but how should the engine know what data to put in place of the template syntax? We need a way to assign data to the engine, so it knows what the valid commands are. And since we’re adding data to the engine, we’ll need a way to retrieve and remove data as well.

The Class

<?php
 
class Template
{
	private $variables = array();
 
	public function AssignVar( $name , $value )
	{
		$this->variables[$name] = $value;
	}
 
	public function GetVar( $name=null )
	{
		if ( is_null($name) )
			return $this->variables;
		elseif ( isset($this->variables[$name]) )
			return $this->variables[$name];
		else
			return false;
	}
 
	public function RemoveVar( $name )
	{
		if ( isset($this->variables[$name]) )
			unset($this->variables[$name]);
	}
 
	public function Parse( $str )
	{
		$search = array();
		$replace = array();
		foreach ( $this->variables as $name => $value )
		{
			$search[] = '{$'. $name .'}';
			$replace[] = $value;
		}
		return str_replace($search, $replace, $str);
	}
}

View: Source

It looks like we have enough to begin, so let’s see how we can utilize our new class

<?php
 
// let's fetch contact.html
if ( file_exists('contact.html') && ($contents = file_get_contents('contact.html')) )
{
	// include and instantiate the template class
	require_once 'Template.php';
	$template = new Template;
 
	// get contact info from database
	require_once 'db.php';
	$rs = mysql_query('select address, phone, email from company', $dblink);
	if ( !$rs || mysql_errno($rs) > 0 || mysql_num_rows($rs) <= 0 )
	{
		// if we cannot find data, display a message and assign empty values
		echo 'Error while retrieving company contact information';
		$template->AssignVar('address', 'N/A');
		$template->AssignVar('phone', 'N/A');
		$template->AssignVar('email', 'N/A');
	} else {
		// if we have data, assign it
		$row = mysql_fetch_array($rs, MYSQL_ASSOC);
		$template->AssignVar('address', $row['address']);
		$template->AssignVar('phone', $row['phone']);
		$template->AssignVar('email', $row['email']);
	}
 
	// and finally display the legible version
	echo $template->Parse( $contents );
} else
	echo 'Could not fetch template.';
 
?>
VN:F [1.9.17_1161]
Rating: 10.0/10 (1 vote cast)
VN:F [1.9.17_1161]
Rating: +3 (from 5 votes)
Categories: How-To, Informative Tags: , , , ,

Building a Custom Error Handler

January 31st, 2010 No comments

Building a custom error handler in PHP is pretty simple, but I thought I’d write a Singleton class and post it up here to give to anyone who wants it. See PHP’s reference site for more information: http://php.net/set_error_handler.

The Class

<?php
 
define('LOG_ERROR_PATH', '/var/www/my_site/log/errors/');
 
class ErrorHandler 
{
	private static $instance;
	private static $initialized = false;
	private static $errors = array();
 
	public static $oldHandler;
 
	public static function Singleton()
	{
		if ( !is_object(self::$instance) )
			self::$instance = new ErrorHandler;
 
		return self::$instance;
	}
 
	public static function Initialize()
	{
		if ( !self::$initialized )
		{
			self::$oldHandler = set_error_handler( array(self::Singleton(), 'Event') );
			self::$initialized = true;
		}
	}
 
	public function Event( $errno , $errstr , $errfile , $errline )	
	{
		$nolog = array(E_DEPRECATED, E_NOTICE, E_WARNING);
 
		if ( !in_array($errno, $nolog) )
		{
			ob_start();
			debug_print_backtrace();  		// this prints on multiple lines, you can use debug_backtrace() or format the output yourself
			$backtrace = ob_get_clean();
			self::$errors[] = array(time(), $errno, $errstr, $errfile, $errline, $backtrace);
		}
	}
 
	private function __construct()
	{
 
	}
 
	public function __clone()
	{
		trigger_error('You cannot clone the ErrorHandler object.', E_USER_ERROR);
	}
 
	public function __destruct()
	{
		if ( count(self::$errors) > 0 )
		{
			// write the error log
			if ( $f = fopen(LOG_ERROR_PATH . date('Ymd') .'.log', 'a+') )
			{
				$str = '';
				foreach ( self::$errors as $err )
				{
					list($time, $errno, $errstr, $errfile, $errline, $backtrace) = $err;
					$str .= date('m/d/Y h:i:s:t a', $time) .' - errno('. $errno .') - '. $errstr .' in '. $errfile .' on line '. $errline .' - '. $_SERVER['SCRIPT_URI'] ."\n-\n". trim($backtrace) ."\n-\n\n";
				}
				fwrite($f, $str, strlen($str)); 
				fclose($f);
			}
		}
	}
}

Usage Example

1
2
3
4
5
6
<?php
 
require_once 'ErrorHandler.php';
ErrorHandler::Initialize();
 
?>
VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: 0 (from 0 votes)

Memcache Session Handler With a MySQL Backup Plan

January 31st, 2010 6 comments

This post aims to demonstrate how to build a custom session handler using memcached and a mysql database. After you’ve gotten memcached installed and running (Memcached Wiki), we can continue. If you use Debian it’s easy:

  1. Install Memcached: sudo apt-get install memcached
  2. Start Memcached: sudo memcached -d -m 1024 -l 127.0.0.1 -p 11211.

    -d means to run it as a daemon (in the background);
    -m designates the amount of memory to use, we have specified 1024 (1GB);
    -l is the location or IP address, we use 127.0.0.1, localhost;
    -p is the port the memcached server runs on, we use 11211

So, first we set up the session table in the database. We do this because if memcached fails, we need to be able to read and write session data. We will write to the database every time, and only read from the database if memcached is inaccessible.

** Tip: we use a ‘mediumtext’ field to store session data. If you use blob or text, mysql will nuke any session that gets too large. http://dev.mysql.com/doc/refman/5.5/en/storage-requirements.html

create table session 
(
	id varchar(50) primary key ,
	data mediumtext null ,
	status enum('active','destroyed','gc','expired') default 'active' ,
	last_modified int not null default 0 ,
	datestamp int not null default 0
);
create index idx_session on session ( status , last_modified desc , datestamp desc );

After the MySQL table is set up, and memcached is running on the server, we can code the session handler: PHP Reference. This code uses DB::GetPDO() to get a PHP PDO Object to run database operations on. You will have to make alterations to those sections when implementing this into your system. To support the script, I have provided a simple DB class below, that has a PDO singleton instance.

The Session Handler

<?php
 
define('MEMCACHE_HOST', '127.0.0.1');
define('MEMCACHE_PORT', 11211);
define('SESSION_NAME', 'MY_SESS_NAME');
 
class Session 
{
	/* session timeout, in seconds - 1800 is 30min */
	const SESSION_TIMEOUT = 1800;
 
	private static $__memcache_connectable = true;
	private static $__memcache;
	private static $instance;
	private static $initialized = false;
	private static $expired = false;
 
	/**
	* retrieve the memcached object
	*
	* @return mixed bool or Memcache object
	*/
	private static function Memcache()
	{
		if ( self::$__memcache_connectable && !isset(self::$__memcache) )
		{
			$m = new Memcache;
			if ( !$m->connect(MEMCACHE_HOST, MEMCACHE_PORT) )
			{
				// if you have a custom error handler, uncomment this
				// trigger_error('Could not connect to memcache server.', E_USER_WARNING);
 
				self::$__memcache_connectable = false;
				return 0;
			}
			self::$__memcache = $m;
			self::$__memcache_connectable = true;
		}
		return self::$__memcache;
	}
 
	/**
	* get the instance to this class
	*
	*/
	public static function Singleton()
	{
		self::Initialize();
		return self::$instance;
	}
 
	/**
	* initialize the session - calls session_start() and closes any sessions open prior
	*
	*/
	public static function Initialize()
	{
		if ( !self::$initialized )
		{
			if ( session_id() != '' )
				session_write_close();
 
			self::$instance = new Session;
			session_set_save_handler( 
				array(self::$instance, 'open'),
				array(self::$instance, 'close'),
				array(self::$instance, 'read'),
				array(self::$instance, 'write'),
				array(self::$instance, 'destroy'),
				array(self::$instance, 'gc') 				// garbage collector
			);
			self::$initialized = true;
 
			// If you rely on using session_set_cookie_params() to set the session-lifetime, you may encounter a bug
			// this function only sets the lifetime of the cookie when the cookie is FIRST created
			// all subsequent requests which use the existing cookie do not have their expire time modified
			// as a result, sessions timeout pre-maturely (unless your lifetime is set to 0) - use setcookie instead (see below)
			// session_set_cookie_params(self::SESSION_TIMEOUT);
 
			// start the session
			session_name( SESSION_NAME );
			session_start();
 
			// make sure PHP tells the cookie to expire properly
			setcookie(SESSION_NAME, session_id(), time()+self::SESSION_TIMEOUT, '/');
		}
	}
 
	/**
	* private constructor - called from self::Singleton
	*
	*/
	private function __construct()
	{
		// you may not instantiate this class
	}
 
	/**
	* when the session object is unset or destroyed this is called to close the session and the memcached connection
	*
	*/
	public function __destruct()
	{
		/*
			As of PHP 5.0.5 the write  and close  handlers are called after object destruction and therefore cannot use objects or throw exceptions. The object destructors can however use sessions.
			It is possible to call session_write_close() from the destructor to solve this chicken and egg problem. 
		*/
		session_write_close();
		if ( is_object(self::$__memcache) )
			self::$__memcache->close();
	}
 
	/**
	* you may not clone the session object
	*
	*/
	public function __clone()
	{
		trigger_error('You cannot clone the Session object.', E_USER_ERROR);
	}
 
	/**
	* session handler callback 'open'
	*
	* @param string $save_path the path the session is located
	* @return bool
	*/
	public function open( $save_path , $session_name )
	{
		// expire sessions
		if ( rand(0,1) && ($pdo = DB::GetPDO()) )
			$pdo->Query('update _session set status = "expired" where status = "active" and last_modified <= '. (time() - self::SESSION_TIMEOUT));
 
		return true;
	}
 
	/**
	* session handler callback 'close'
	*
	*/
	public function close()
	{
		return true;
	}
 
	/**
	* session handler callback 'read' - reads data from memcached when possible and from the db otherwise - if objects have a __wakeup, this is where they get called from
	*
	* @param string $sessid the session id
	* @return mixed false or the unserialized data from the session
	*/
	public function read( $sessid )
	{
		// read from memcache when possible
		if ( $m = self::Memcache() )
		{
			if ( $data = $m->get($sessid) )
				return $data;
		}
 
		// read from DB when memcache is inaccessible or does not return anything
		if ( $pdo = DB::GetPDO() )
		{
			if ( $st = $pdo->query('select data , last_modified , status from _session where id = '. $pdo->quote($sessid) .' and status = "active"') )
			{
				if ( $st->RowCount() > 0 && ($data = $st->FetchAll(PDO::FETCH_OBJ)) )
				{
					if ( $data[0]->last_modified <= time() - self::SESSION_TIMEOUT )
					{
						/**
						* if the session id cookie is set properlly and expires properlly, we should never get here
						* 
						* if we do get here, we have an active session whos last modified is too old, so we need to mark it as expired
						* since this was a request with an old cookie id, we invalidate that cookie and tell self::write not to store its data 
						*/
						$pdo->query('update _session set status = "expired" where id = '. $pdo->quote($sessid));
 
						// expire the session id cookie
						setcookie(SESSION_NAME, 'expired', 1, '/');
 
						self::$expired = true;
						return false;
					}
					return $data[0]->data;
				}
			}
		} else
			trigger_error('Session::read could not fetch a PDO object; mysql session storage fails.', E_USER_ERROR);
 
		return false;
	}
 
	/**
	* session handler callback 'write' - writes data to memcached and to the db every time
	*
	* @param string $sessid the session id
	* @param mixed $data the data being written to the session in an unserialized format
	*/
	public function write( $sessid , $data )
	{
		// if this session is expired, do not write
		if ( self::$expired )
			return;
 
		// always write to memcache
		if ( $m = self::Memcache() )
			$m->set($sessid, $data, false, self::SESSION_TIMEOUT);
 
		// always write to DB
		if( $pdo = DB::GetPDO() )
		{
			$st = $pdo->query('update session set data = '. $pdo->quote($data) .' , status = "active" , last_modified = '. time() .' where id = '. $pdo->quote($sessid));
			if ( !$st || $st->rowCount() <= 0 )
				$pdo->query('insert into session ( id , data , datestamp , last_modified ) values ( '. $pdo->quote($sessid) .' , '. $pdo->quote($data) .' , '. time() .' , '. time() .' )');
		}
	}
 
	/**
	* session handler callback 'destroy' - removes the specified session from memcached and from the db
	*
	* @param string $sessid the session id
	*/
	public function destroy( $sessid )
	{
		// remove from memcache
		if ( $m = self::Memcache() )
			$m->delete($sessid, 0);
 
		// remove from db
		if ( $pdo = DB::GetPDO() )
			$pdo->query('update session set status = "destroyed" where id = '. $pdo->quote($sessid));
	}
 
	/**
	* session handler callback 'gc' - the garbage collector removes old sessions that have been expired for at least 60 seconds
	*
	* @param int $max_sess_lifetime the maximum session lifetime (timeout) in seconds that a session can exist
	*/
	public function gc( $max_sess_lifetime )
	{
		if ( $pdo = DB::GetPDO() )
			$pdo->query('update _session set status = "gc" where status in ("active","expired") and last_modified + '. ($max_sess_lifetime + 60) .' < '. time());
		else
			trigger_error('Session::gc could not fetch a pdo object; garbage collection fails.', E_USER_WARNING);
	}
}

View: Session Class

DB Class

<?php
 
define('DATABASE_DSN', 'mysql:dbname=database_name;host=127.0.0.1;');
define('DATABASE_USERNAME', 'user');
define('DATABASE_PASSWORD', 'password');
 
class DB
{
	private static $pdo;
 
	public function GetPDO()
	{
		if ( !is_object(self::$pdo) )
			self::$pdo = new PDO(DATABASE_DSN, DATABASE_USERNAME, DATABASE_PASSWORD);
 
		return self::$pdo;
	}
}

Usage Example

1
2
3
4
5
6
7
<?php
 
require_once 'Session.php';
Session::Initialize();
 
// continue to use sessions normally
$_SESSION['myvar'] = 'some data';
VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: +2 (from 4 votes)

Authorize.NET Payment Gateway

January 31st, 2010 1 comment

If you have a client that uses Authorize.NET, this can save you some time. It was written a long time ago but it’s still clean; I modified it from PHP 4 to PHP 5 for this post. Eventually, I will post a Payment Gateway Factory, so keep it locked.

Authorize.NET Payment Gateway Class

<?php
define("API_LOGIN_ID", "YOUR_LOGIN_ID");
define("API_TRANSFER_KEY", "YOUR_TRANSFER_KEY");
// define("API_URL", "https://certification.authorize.net/gateway/transact.dll");
define("API_URL", "https://secure.authorize.net/gateway/transact.dll");
 
class AuthorizeNet
{
    private $post_array = array(
        "x_login"                => API_LOGIN_ID ,
        "x_version"                => "3.1",
        "x_delim_char"            => "|",
        "x_delim_data"            => "TRUE",
        "x_url"                    => "FALSE",
        "x_type"                => "AUTH_CAPTURE",
        "x_method"                => "CC",
        "x_tran_key"            => API_TRANSFER_KEY ,
        "x_relay_response"        => "FALSE" ,
        "x_card_num"            => null ,
        "x_card_code"            => null ,
        "x_exp_date"            => null ,
        "x_description"            => null ,
        "x_amount"                => null ,
        "x_first_name"            => null ,
        "x_last_name"            => null ,
        "x_address"                => null ,
        "x_city"                => null ,
        "x_state"                => null ,
        "x_zip"                    => null ,
        "x_country"                => "US"
    );
 
    // response variables, populated after each process
    public $response_code;
    public $response_subcode;
    public $response_reason_code;
    public $response_reason_text;
    public $approval_code;
    public $avs_result_code;
    public $transaction_id;
    public $invoice_number;
    public $description;
    public $amount;
    public $method;
    public $transaction_type;
    public $customer_id;
 
    function cc_info($num, $exp, $code)
    {
        $this->post_array["x_card_num"] = $num;
        $this->post_array["x_exp_date"] = $exp;
        $this->post_array["x_card_code"] = $code;
    }
 
    function user_info($fname, $lname, $city, $state, $zip, $country = "US")
    {
        $this->post_array["x_first_name"] = $fname;
        $this->post_array["x_last_name"] = $lname;
        $this->post_array["x_city"] = $city;
        $this->post_array["x_state"] = $state;
        $this->post_array["x_country"] = $country;
    }
 
    function order_info($amount, $desc = "Online Transaction")
    {
        $this->post_array["x_amount"] = (float)$amount;
        $this->post_array["x_description"] = $desc;
    }
 
    function validate_funds()
    {
        $this->post_array["x_type"] = "AUTH_ONLY";
        return( $this->process_transaction() );
    }
 
    function process_funds()
    {
        $this->post_array["x_type"] = "AUTH_CAPTURE";
        return( $this->process_transaction() );
    }
 
    function process_transaction()
    {
        $post_data = "";
        foreach( $this->post_array as $key => $value )
            $post_data .= (($post_data != "") ? "&" : "") . $key ."=". urlencode( $value );
 
        // initialize response data
        $this->response_code = null;
        $this->response_subcode = null;
        $this->response_reason_code = null;
        $this->response_reason_text = null;
        $this->approval_code = null;
        $this->avs_result_code = null;
        $this->transaction_id = null;
        $this->invoice_number = null;
        $this->description = null;
        $this->amount = null;
        $this->method = null;
        $this->transaction_type = null;
        $this->customer_id = null;
 
        if ( ($ch = curl_init( API_URL )) )
        {
            curl_setopt( $ch , CURLOPT_HEADER , 0 );                                    // set to 0 to eliminate header info from response
            curl_setopt( $ch , CURLOPT_RETURNTRANSFER , 1 );                            // Returns response data instead of TRUE(1)
            curl_setopt( $ch , CURLOPT_POSTFIELDS , $post_data );                        // use HTTP POST to send form data
            curl_setopt( $ch , CURLOPT_SSL_VERIFYPEER , false );                        // uncomment this line if you get no gateway response.
 
            $resp = curl_exec($ch);                                                        //execute post and get results
            curl_close($ch);
 
            return( $this->parse_transaction($resp) );
        } else
            return( false );
    }
 
    function parse_transaction($str)
    {
        if ( strpos($str, "|") !== false )
        {
            $arr = explode("|", $str);
 
            $this->response_code = $arr[0];
            $this->response_subcode = $arr[1];
            $this->response_reason_code = $arr[2];
            $this->response_reason_text = $arr[3];
            $this->approval_code = $arr[4];
            $this->avs_result_code = $arr[5];
            $this->transaction_id = $arr[6];
            $this->invoice_number = $arr[7];
            $this->description = $arr[8];
            $this->amount = $arr[9];
            $this->method = $arr[10];
            $this->transaction_type = $arr[11];
            $this->customer_id = $arr[12];
 
            if ( (int)$this->response_code != 1 )
                return( false );
            else
                return( true );
        } else
            return( false );
    }
}

Usage Example

1
2
3
4
5
6
7
8
9
<?php
$gateway = new AuthorizeNET;
$gateway->user_info($fname, $lname, $city, $state, $zip, $country);
$gateway->order_info($amount, 'Order ID #'. $order_id);
if ( $gateway->process_transaction() )
    echo 'Transaction Processed Successfully.  Thank You!  Your order id is: '. $order_id;
else
    echo 'There was an error while processing your transaction: ('. $gateway->response_code .') '. $gateway->response_reason_text;
?>

It’s that easy! Enjoy.

VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: 0 (from 0 votes)

Welcome to PHP Professional!

January 31st, 2010 No comments

Hello and welcome to PHP Professional.  This blog is here for others to reference when in need, but its sole purpose drives to encourage me in my constant effort towards growth.  Please drop a comment and let me know if you like my work.

Check back soon for posts…

VN:F [1.9.17_1161]
Rating: 0.0/10 (0 votes cast)
VN:F [1.9.17_1161]
Rating: 0 (from 0 votes)
Categories: Website Tags: