Showing posts with label MVC. Show all posts
Showing posts with label MVC. Show all posts

How to implement really small and fast ORM with PHP (Part 7: IDE)

Fork me on GitHub

Queries are gaining more and more complexity, data is getting bigger and bigger. Most optimizations in database technology are done in the database server. This is an approach to optimize queries on the client side.

With this ORM, queries ...
  • don't select more data than needed
  • contain less joins when data is expected to be consistent
  • can be written manually in pure SQL
  • are not written in a new query language

We need a good API, so ...

  • it should be easy to learn
  • method names must be short and intuitive
  • the goal is to map datasets and relations to objects
  • the API should offer method chaining
  • special features like auto-increments should be included
  • the code should be small, no getters and setters
  • the database schema is created before writing PHP code
  • relationships should be defined in the database, not in the code
  • we get low latencies combined with low memory usage

To make things easier, we make some restrictions:

  • only UTF-8
  • only MySQL/MariaDB (mysqli)
  • only PHP 5.4.0+
  • only buffered queries

Our ORM should have the same efficiency as handwritten SQL. So the following statements should produce only 1 query:

  • DBo::Guestbook(42)->Comments()->delete(); // "Guestbook" and "Comments" are tables
  • DBo::Guestbook(42)->Comments()->update('hidden', 1);
  • DBo::Guestbook(42)->Comments()->count();
  • DBo::Student()->Attend()->Lecture()->Uses()->Book(); // "Uses", "Book", etc. are tables

The following statements should only update 1 column:

  • DBo::Guestbook(42)->Comments()->update('hidden', 1);
  • DBo::Guestbook(42)->update('title', 'hello');

The following statements should only select 1 column (that's the hard one):

  • foreach (DBo::Guestbook() as $gb) echo $gb->title;

This gets implemented with a two-pass method: We store the columns used in the first run in DBo::usage_col[code position] and reuse it in the second run. The "code position" is the line where the constructor was called (returned from debug_backtrace()).

Meta data like columns or indexes should be fetched statically into the code (not during runtime). To create a join out of "Guestbook()->Comments()", the relevant columns are chosen during runtime. The syntax is:
  • table.primary_key_field = other_table.table_primary_key_field
  • e.g. sale.id = salepos.sale_id

To export the schema, we use:


// export schema to schema.php
DBo::conn(new mysqli('127.0.0.1', 'root', 'some_pw'));
DBo::exportSchema();

To keep control over the queries, we allow normal queries and debugging:


$entries = DBo::query('SELECT * FROM guestbook WHERE id=?', [42]); // Iterator
foreach ($entries as $obj) {...}

$id = DBo::query('INSERT INTO guestbook VALUES (...)'); // LastInsert ID
// 42

$affected = DBo::query('UPDATE guestbook SET active=0'); // Affected rows
// 10

$subject = DBo::value('SELECT subject FROM guestbook WHERE id=42'); // String
// 'Hello World'

$categories = DBo::values('SELECT DISTINCT categories FROM guestbook'); // Array
// ['Sports', 'Movies', 'Music']

$row = DBo::one('SELECT * FROM guestbook WHERE id=42'); // Array
// [id=>42, title=>'hello']

$row = DBo::keyValue('SELECT id,title FROM guestbook'); // Array
// [42=>'hello', 43=>'world']

$row = DBo::keyValues('SELECT id,title,subject FROM guestbook'); // Array
// [42=>['title'=>'hello', 'subject'=>'world'], 43=>['title'=>...]

echo DBo::Guestbook(42)->Comments(); // SQL String
// SELECT a.* FROM Comments a, Guestbook b WHERE ...

echo DBo::Guestbook(42)->Comments()->explain(); // explain SQL string
// EXPLAIN SELECT ... id | select_type | table | type ...

DBo::Guestbook(42)->Comments()->print_r(); // print_r related comments
// Array( id=... )

We also allow transactions:


DBo::begin();
$dbo = DBo::Guestbook(42);
$dbo->Comments()->delete(); // DELETE FROM Comments ...
$dbo->update('comments_count', 0); // UPDATE Guestbook SET ...
DBo::commit();

Here are some examples how the ORM should work:


// create a new entry in table "Guestbook"
// set attribute values to "hello" and "world"
// finally print out the primary key (auto-generated by auto-increment)
// - gives INSERT INTO Guestbook SET subject='hello', details='world'
$obj = DBo::Guestbook();
$obj->subject = 'hello';
$obj->details = 'world';
echo $obj->insert(); // 43 (from auto_increment)
echo $obj->id; // 43
// or $_POST = ['subject'=>'hello', ...]
echo DBo::Guestbook()->insert($_POST); // 43

// map entry in table "Guestbook" with primary key "42" to "$obj"
// - gives SELECT * FROM Guestbook WHERE id=42
$obj = DBo::Guestbook(42);
if (!$obj->exists()) {...}
echo $obj->subject;
// or
echo DBo::Guestbook(42)->subject;

// update antry in table "Guestbook" with primary key "42", set "hidden" to "1"
// - gives UPDATE Guestbook SET hidden=1 WHERE id=42
$obj = DBo::Guestbook(42);
$obj->hidden = 1;
$obj->update();
// or
DBo::Guestbook(42)->update('hidden', 1);
// or $_POST = ['hidden'=>'1', ...]
DBo::Guestbook(42)->update($_POST);

// delete entry in table "Guestbook" with primary key "42"
// - gives DELETE FROM Guestbook WHERE id=42
DBo::Guestbook(42)->delete();

// increment a field in table "Guestbook" with primary key "42"
// - gives UPDATE Guestbook SET likes=likes+1 WHERE id=42
DBo::Guestbook(42)->update('likes=likes+1');

Doing 1:n and n:m relations should be also very easy:


// get all Comments for Guestbook entry with primary key 42
// join a 1:n relationship (table.id = table2.table_id)
// - gives SELECT * FROM Comments WHERE guestbook_id=42
$comments = DBo::Guestbook(42)->Comments();
foreach ($comments as $comment) {...}

// update comments
// - gives UPDATE Comments SET hidden=1 WHERE guestbook_id=24
// - note that traditional ORMs do one update statement for each dataset
DBo::Guestbook(42)->Comments()->update('hidden', 1);

// update comments with where predicate
// - gives UPDATE Comments SET active=0 WHERE active=1 AND guestbook_id=24
DBo::Guestbook(42)->Comments('active=1')->update('active', 0);

// deleting comments works in the same way
// - gives DELETE FROM Comments WHERE guestbook_id=24
DBo::Guestbook(42)->Comments()->delete();

// n:m Students attend Lectures
// - gives SELECT a.* FROM Lecture a, Attend b WHERE b.student_id=21 AND
// b.lecture_id = a.id
$lectures = DBo::Student(21)->Attend()->Lecture();
foreach ($lectures as $lecture) {...}

// n:m Students attend Lectures, Lecture uses Books
// - gives SELECT a.* FROM Book a, Uses b, Lecture c, Attend d
// WHERE d.student_id = 21 AND d.lecture_id = c.id
// AND c.id = b.lecture_id AND b.book_id = a.id
$books = DBo::Student(21)->Attend()->Lecture()->Uses()->Book();
foreach ($books as $book) {...}

Sometimes it is better to avoid normalization and store multiple values inside a string. This reduces the number of tables, relations and costly joins, e.g. using a string value like "100,101,102" instead of a join. The data can be also encoded as a JSON string with '[100,101,102]' or '["100","101","102"]'. To do the encoding and decoding automatically, the names of the members can be amended with "_arr" and "_json". Here is an example:


// automatic encoding and decoding of values
// - gives UPDATE Guestbook SET tags_arr='sport,music,tv' WHERE id=42
// UPDATE Guestbook SET tags_json='{"a":"b","c":"d"}' WHERE id=42
$obj = DBo::Guestbook(42);
$obj->update('tags_arr', ['sport','music','tv']); // field tags (Varchar)
// or
$obj->update('tags_json', ['a'=>'b', 'c'=>'d']); // field tags2 (Varchar)

$obj = DBo::Guestbook(42);
print_r($obj->tags_arr); // Array([0] => sport\n [1] => music\n [2] => tv)
// or
print_r($obj->tags_json); // Array([a] => b\n [c] => d)

Predicates can be defined in several ways:


// select one dataset
// - gives SELECT * FROM Guestbook WHERE id=10
DBo::Guestbook('id=10');
DBo::Guestbook('id=?', 10);
DBo::Guestbook(['id'=>10]);
DBo::Guestbook(10); // id is a numeric primary key

// select multiple datasets
// - gives SELECT * FROM Guestbook WHERE id IN (10,11,12)
DBo::Guestbook('id in (10,11,12)');
DBo::Guestbook('id in ?', [10,11,12]);
DBo::Guestbook(['id'=>[10,11,12]]);
DBo::Guestbook([10,11,12]); // id is a primary key

// select multiple primary keys
// - gives SELECT * FROM Guestbook WHERE (id,id2) IN ((10,11))
DBo::Guestbook('id=10 and id2=11');
DBo::Guestbook('(id,id2) in ?', [10,11]);
DBo::Guestbook('id=? and id2=?', 10, 11);
DBo::Guestbook(['id'=>10, 'id2'=>11]);
DBo::Guestbook([[10,11]]); // id and id2 are a primary key

// select multiple datasets with multiple primary keys
// - gives SELECT * FROM Guestbook WHERE (id,id2) IN ((10,1),(11,2))
DBo::Guestbook('(id,id2) in ((10,1), (11,2))');
DBo::Guestbook('(id,id2) in ?', [[10,1], [11,2]]);
DBo::Guestbook([[10,1], [11,2]]); // id and id2 are a primary key

// additional predicates
DBo::Guestbook(10, "active=1");
DBo::Guestbook("active=1 and foo=?", "bar");

// limit
foreach (DBo::Order("status=open")->limit(100) as $obj) {...

Joins are skipped when data is expected to be consistent:


foreach (DBo::guestbook(42)->comments() as $comment) {...}
// gives SELECT * FROM comments WHERE guestbook_id=42

Custom SQL can be used:


$comments = DBo::object("SELECT * FROM Comments WHERE guestbook_id=?", [42]);
foreach ($comments as $comment) {...}

Aggregate functions can be used:


// SELECT count(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->count(); // 40

// SELECT sum(price) FROM order where customer_id=42
DBo::Customer(42)->Order()->sum('price'); // 600

// SELECT avg(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->avg('price'); // 23

// SELECT stddev(*) FROM order where customer_id=42
DBo::Customer(42)->Order()->stddev('price'); // 8

Data can be archived:


// archiving is not yet implemented

// create archive table
// CREATE TABLE Customer_archive LIKE Customer;
// # remove primary & auto_increment, add index and timestamp
// ALTER TABLE Customer_archive DROP primary key, MODIFY id int,
// ADD index(id), ADD ts timestamp;

// archive record before updating
// Customer.id = primary key, Customer_archive.id = index
// INSERT INTO test.Customer_archive
// SELECT a.*, now() FROM test.Customer a WHERE a.id=42
// UPDATE test.Customer a SET a.hidden=1 WHERE a.id=42
DBo::Customer(42)->archive()->update('hidden', 1);

// copy record to table
// INSERT INTO somedb.sometable
// SELECT a.* FROM test.Customer a WHERE a.id=42
DBo::Customer(42)->copyTo('somedb.sometable');

Custom classes can also be used:


class DBo_Sales extends DBo {
public function buildData($insert=false) {
// custom validation
$data = parent::buildData();
if (empty($data["some_val"])) throw new Exception(...);
return $data;
}

public function insert($arr=null) {
// execute some pre trigger
parent::insert($arr);
// execute some post trigger
}

public function delete() {
if ($this->status != 'draft') throw new Exception(...);
parent::delete();
}

public function completed() {
return DBo::query('SELECT * FROM sales WHERE completed=1');
}

public function get_age() {
return date_diff(new DateTime($this->birthdate), new DateTime())->y;
}
}

// DBo_{table} is automatically used as class
print_r(DBo::Sales());
=> DBo_Sales Object (...

print_r(DBo::Sales()->completed());
=> returns completed() from Dbo_Sales

print_r(DBo::object("SELECT * FROM Sales")->completed());
=> returns completed() from Dbo_Sales

// get_{field}() is automatically used when the member not exists
print_r(DBo::Sales()->age);
=> returns get_age()

The database connection should be opened when the first query is being executed. We use:


class mysqli_lazy extends mysqli {
public function query($query) {
if (!@$this->host_info) parent::connect('127.0.0.1', 'root', '', 'db');
// or persistent connection: 'p:127.0.0.1'
return parent::query($query);
}
}
DBo::conn(new mysqli_lazy, 'db');

// instead of
DBo::conn(new mysqli('127.0.0.1', 'root', '', 'db'), 'db');
// or persistent connection: 'p:127.0.0.1'

To get all queries on stdout, we use:


class mysqli_log extends mysqli {
public function query($query) {
echo $query."\n";
return parent::query($query);
}
}
DBo::conn(new mysqli_log('127.0.0.1', 'root', '', 'db'), 'db');

The fastest way to get data is reading it from a hash table in the main memory. With the APC extension, we can persist data between many requests. The syntax for caching looks like this:


// caching is not yet implemented

// cache categories for 60 seconds
$payments = DBo::Categories()->cache(60);
// [{id=>0, name=>Sports}, {id=>1, name=>Movies}, ...]

$payments = DBo::Categories()->oarray(60);
// [[id=>0, name=>Sports], [id=>1, name=>Movies], ...]

$payments = DBo::Categories()->ovalues('col_name', 60);
// [Sports, Movies, ...]

$payments = DBo::Categories()->okeyValue('col_id', 'col_name', 60);
// [0=>Sports, 1=>Movies, ...]

$payments = DBo::Categories()->count(60); // 42

Broken queries or connection errors can be handled with try-catch:


try {
DBo::conn(new mysqli('127.0.0.1', 'root', '', 'db'), 'db');
DBo::query('select * from invalid');
}
catch (mysqli_sql_exception $e) {
echo $e->getMessage();
exit(1);
}

If column names are ambiguous, we need to prefix them with "@":


// SELECT a.* FROM app a, os b WHERE a.os_id=b.id AND id=42 AND id=13
DBo::os('id=42')->app('id=13');

// SELECT a.* FROM app a, os b WHERE a.os_id=b.id AND b.id=42 AND a.id=13
DBo::os('@id=42')->app('@id=13');

Security:

Scalar parameters are escaped automatically if the previous parameter is a string. When using scalar parameters without predicates, you need to cast them manually:


DBo::Guestbook('id=?', $_GET['id']); // automatic
DBo::Guestbook((int)$_GET['id']); // manual!
DBo::Guestbook((array)$_GET['ids']); // manual!

Other parameters and field names are automatically escaped:


DBo::Guestbook([ $_GET['id'], $_GET['val'] ]); // automatic
DBo::Guestbook([ 'id' => $_GET['id'] ]); // automatic

DBo::Guestbook()->setFrom($_POST)->insert(); // automatic
DBo::Guestbook()->limit($_POST['limit']); // automatic

IDE integration: (e.g. PhpStorm)


// auto-generation of PHPdoc hints is not yet implemented

// type hint
$sales = DBo::Sales(); /* @var $sales DBo_Sales */

// or class hint
/**
* @method static DBo_Sales Sales some description
*/
class DBo implements IteratorAggregate {...}

// or extended class hint
/**
* @method static DBo_Sales Sales some description
*/
class HDBo extends DBo {}

$sales = HDBo::Sales();

/**
* @property int id
* @property decimal_6_2 price
* @property varchar_40 desc
*/
class DBo_Sales extends DBo {}





Keyboard shortcuts:
- auto-complete: Ctrl+space
- documentation: Ctrl+q
- variable info: Ctrl+mouseover


The implementation in detail:

Running DBo::Student()->Attend()->Lecture()->array() as a single query can be implemented by using a stack. The query itself presents a chain of tables being joined by some predicates. Attend() and others are handled by PHP's magic __call(). Every table in the chain pushes a new element on the stack containing the name of the table, the primary key and the required predicate(s).

Even with the best ORM, you can still write bad code:


foreach (DBo::os(10)->app() as $app) {
if (!$app->active) continue; // bad
...
}
foreach (DBo::os(10)->app('active=1') as $app) {...} // good, less data

foreach (DBo::os(10)->app() as $app) {
foreach ($app->compontent() as $compontent) {...} // bad, many queries
}
foreach (DBo::os(10)->app()->compontent() as $compontent) {...} // good

foreach (DBo::os(10)->app() as $app) $app->update('active', 1); // bad
DBo::os(10)->app()->update('active', 1); // good, 1 query

foreach (DBo::sale(10)->salepos() as $salepos) {
if (DBo::logistics($salepos->id, 'shipped=1')->exists()) {
$salepos->update('complete', 1); // bad
}
}
DBo::sale(10)->salepos()->logistics('shipped=1')
->salepos()->update('complete', 1); // good

In general, it is better to select as little data as possible from the database. Joins are often expensive, but if the database handles them correctly, it is much more efficient than performing joins directly in PHP.

Changelog:


The code is available at: https://github.com/thomasbley/DBo (~500 loc, test coverage 100%)


How to write a really small and fast controller with PHP (update: benchmark Slim, Silex, Zend Framework, Symfony2)

To handle a lot of traffic, we need a fast controller with very little memory overhead. First, we implement a dynamic controller.

The design is based on the micro frameworks Slim and Silex. The first example maps the URL "http://server/index.php/blog/2012/03/02" to a function with the parameters $year, $month and $day:

// index.php, handle /blog/2012/03/02
$app = new App();
$app->get('/blog/:year/:month/:day', function($year, $month, $day) {
printf('%d-%02d-%02d', $year, $month, $day);
});
Our controller is a class named App and uses the get() function to map a GET request. Parameters mapped to the function are marked with a colon. Optional parameters are written inside brackets. Here is an example:

// handle /blog, /blog/2012, /blog/2012/03 and /blog/2012/03/02
$app = new App();
$app->get('/blog(/:year(/:month(/:day)))', function($year=2012, $month=1,
$day=1) {
printf('%d-%02d-%02d', $year, $month, $day);
});

Instead of printf(), we can use quote() to replace < and > with HTML entities. In the second example we map "index.php/product/42/super-coding-book" to a function with the parameter $id. We use * in the URL to match any character different from "/":

// index.php, handle /product/42/seo-text
$app = new App();
$app->get('/product/:id/*', function($id) use ($app) {
echo 'You selected '.$app->quote($id);
});

In the third example we map "index.php/profile/jdoe" to a function with the parameter $username and render the output with a PHP template:

// index.php, handle /profile/jdoe
$app = new App();
$app->get('/profile/:username', function($username) use ($app) {
$app->message = 'Hello '.$username;
$app->display('profile.php');
});

// profile.php
<html><body>
Message: <?= $this->quote($this->message) ?>
</body></html>

Instead of using anonymous functions, we can also forward the request to a normal function. In the next example, the request is forwarded to the static method greet() in the class Hello:

// forwards /hello/world to Hello::greet('world')
$app = new App();
$app->get('/hello/:name', 'Hello::greet');

class Hello {
static function greet($name) {
echo 'Hello '.$name;
}
}

The controller is also able to forward the request to more than one function. In this example, the request also calls header() and footer() from the Page class:

// forwards /welcome to Page::header(); User::show(); Page::footer();
$app->get('/welcome', ['Page::header', 'User::show()', 'Page::footer']);

To make testing easier, we can use a decorator subclassing to convert the output of a function to JSON:

$app = new AppJson();
$app->get('/json/range', function() {
return range(0, 10);
});

// output
[0,1,2,3,4,5,6,7,8,9,10]

Instead of named parameters, we can also use anonymous parameters with the get_p() function:

// index.php, handle /blog/2012/03/02
$app = new App();
$app->get_p('/blog/:p/:p/:p', function($year, $month, $day) {
printf('%d-%02d-%02d', $year, $month, $day);
});
Note that get_p() is 40 percent faster than get().

And finally, here is the controller:

set_exception_handler('App::exception'); // bootstrap

class App {
protected $_server = [];

public function __construct() {
// skipped mocking here
$this->_server = &$_SERVER;
}

public function get($pattern, $callback) {
$this->_route('GET', $pattern, $callback);
}

public function get_p($pattern, $callback) {
$this->_route_p('GET', $pattern, $callback);
}

public function delete($pattern, $callback) {
$this->_route('DELETE', $pattern, $callback);
}

protected function _route($method, $pattern, $callback) {
if ($this->_server['REQUEST_METHOD']!=$method) return;

// convert URL parameter (e.g. ":id", "*") to regular expression
$regex = preg_replace('#:([\w]+)#', '(?<\\1>[^/]+)',
str_replace(['*', ')'], ['[^/]+', ')?'], $pattern));
if (substr($pattern,-1)==='/') $regex .= '?';

// extract parameter values from URL if route matches the current request
if (!preg_match('#^'.$regex.'$#', $this->_server['PATH_INFO'], $values)) {
return;
}
// extract parameter names from URL
preg_match_all('#:([\w]+)#', $pattern, $params, PREG_PATTERN_ORDER);
$args = [];
foreach ($params[1] as $param) {
if (isset($values[$param])) $args[] = urldecode($values[$param]);
}
$this->_exec($callback, $args);
}

protected function _route_p($method, $pattern, $callback) {
if ($this->_server['REQUEST_METHOD']!=$method) return;

// convert URL parameters (":p", "*") to regular expression
$regex = str_replace(['*','(',')',':p'], ['[^/]+','(?:',')?','([^/]+)'],
$pattern);
if (substr($pattern,-1)==='/') $regex .= '?';

// extract parameter values from URL if route matches the current request
if (!preg_match('#^'.$regex.'$#', $this->_server['PATH_INFO'], $values)) {
return;
}
// decode URL parameters
array_shift($values);
foreach ($values as $key=>$value) $values[$key] = urldecode($value);
$this->_exec($callback, $values);
}

protected function _exec(&$callback, &$args) {
foreach ((array)$callback as $cb) call_user_func_array($cb, $args);
throw new Halt(); // Exception instead of exit;
}

// Stop execution on exception and log as E_USER_WARNING
public static function exception($e) {
if ($e instanceof Halt) return;
trigger_error($e->getMessage()."\n".$e->getTraceAsString(), E_USER_WARNING);
$app = new App();
$app->display('exception.php', 500);
}

public function quote($str) {
return htmlspecialchars($str, ENT_QUOTES);
}

public function render($template) {
ob_start();
include($template);
return ob_get_clean();
}

public function display($template, $status=null) {
if ($status) header('HTTP/1.1 '.$status);
include($template);
}

public function __get($name) {
if (isset($_REQUEST[$name])) return $_REQUEST[$name];
return '';
}
}

class AppJson extends App {
protected function _exec(&$callback, &$args) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(call_user_func_array($callback, $args));
throw new Halt(); // Exception instead of exit;
}
}

// use Halt-Exception instead of exit;
class Halt extends Exception {}

The controller fits perfectly into a high traffic scenario:
  • less than 100 lines of code
  • memory overhead less than 256 KB
  • runtime less than 1 ms

Here are some benchmarks (1.4 GHz):
Name without APC [seconds] with APC [seconds]
App 0.0009 0.0005
Slim 1.6.4 0.0159 0.0083
Silex 0.0596 0.0221
ZendFramework 1.11 0.1625 0.0631
Symfony 2.0.16 0.1968 0.0362
The numbers show that our controller is 17 times faster than Slim, 44 times faster than Silex, 72 times faster than Symfony and 126 times faster than ZF.

Here is the code:

// App
$start = microtime(true);
require 'App.php';
try {
$app = new App();
$app->get('/hello/:name', function ($name) use ($app) {
$app->name = $name;
$app->display('foo.php');
// foo.php: echo 'Hello '.$this->quote($this->name);
});
} catch (Exception $e) {}
echo ' '.(microtime(true)-$start);

// Slim
$start = microtime(true);
require 'Slim/Slim.php';
$app = new Slim();
$app->get('/hello/:name', function ($name) use ($app) {
$app->render('foo.php', ['name' => $name]);
// foo.php: echo 'Hello '.htmlspecialchars($name);
});
$app->run();
echo ' '.(microtime(true)-$start);

// Silex
$start = microtime(true);
require 'silex/vendor/autoload.php';
$app = new Silex\Application();
$app->get('/hello/{name}', function($name) use($app) {
require('templates/foo.php');
// foo.php: echo 'Hello '.htmlspecialchars($name);
});
$app->run();
echo ' '.(microtime(true)-$start);

// ZendFramework
// zf create project helloworld
// helloworld/application/controllers/IndexController.php
class IndexController extends Zend_Controller_Action {
public function helloAction() {
$this->view->name = $this->getRequest()->getParam('name');
}
}
// helloworld/application/views/scripts/index/hello.phtml
Hello <?= $this->escape($this->name); ?>
// helloworld/public.php
$start = microtime(true);
...
echo ' '.(microtime(true)-$start);
// GET /index.php/index/hello/?name=world

// Symfony2
$start = microtime(true);
require_once __DIR__.'/../app/bootstrap.php.cache';
require_once __DIR__.'/../app/AppKernel.php';
$kernel = new AppKernel('dev', false);
$kernel->loadClassCache();
$kernel->handle(Symfony\Component\HttpFoundation\Request::createFromGlobals())->send();
echo ' '.(microtime(true)-$start);
// src\Acme\DemoBundle\Controller\DemoController.php
namespace Acme\DemoBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
class DemoController extends Controller {
/**
* @Route("/hello/{name}", name="_demo_hello")
*/
public function helloAction($name) {
return new Response('Hello '.htmlspecialchars($name, ENT_QUOTES));
}
}
// GET /web/index.php/demo/hello/World

Lessons learned:
  • A good controller can speed up requests by a factor of 100
  • A good controller is the base for all kinds of performance optimizations
  • Controllers included in PHP frameworks are slow

Next: even more performance with static controller, parameter validation, Post and Put methods, File uploads, benchmark ZendFramework 2.0, Symfony 2.1

Replace Smarty with PHP templates

In many performance guides, Smarty is considered to be removed to speed up things. But oftentimes it's not Smarty causing performance problems, but rather big modifier chains not being cached. To point this out, we need to profile our template which is quite difficult when Smarty compiles in into something unreadable. So we need a quick and easy way to replace the Smarty template engine with pure PHP code. Since Smarty can't do more than PHP, let's replace Smarty with simple PHP templates.

So I'm providing here a small guide to replace Smarty with simple PHP based templates. These can be also cached by APC without any compiler.

First thing: Smarty configuration files
e.g. core.conf
foo = bar

[core]
logo = public/img/logo.png
link = http://www.simple-groupware.de
notice = Photo from xy
bg_grey = #F5F5F5

Now let's convert it to PHP:
core_conf.php
<?php
$config = array(
"logo" => "public/img/logo.png",
"bg_grey" => "#F5F5F5"
);
$config["core"] = array(
"logo" => "public/img/logocore.png",
"link" => "http://www.simple-groupware.de",
"notice" => "Photo from xy"
);

Next thing: Smarty templates
main.tpl
{config_load file="core.conf" section="core"}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{$smarty.const.TITLE|escape:"html"}:
{$page.title|default:$page.name|escape:"html"}</title>
<meta name="description" content="{$description|escape:"html"}" />
<meta http-equiv="expires" content="{"r"|date}">
</head>
<body>
<img src="{#logo#}" />
<ul style="background-color: {#bg_grey#};">
{foreach from=$list key=key item=item}
<li>{$key}: {$item|escape:"html"}</li>
{/foreach}
</ul>
</body>
</html>

Conversion to PHP:
main_tpl.php
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> 
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title><?= q(TITLE) ?>:
<?= q($this->page['title'] ?: $this->page['name']) ?></title>
<meta name="description" content="<?= q($this->description) ?>" />
<meta http-equiv="expires" content="<?= date('r') ?>">
</head>
<body>
<img src="<?= $this->c('core', 'logo') ?>" />
<ul style="background-color: <?= $this->c('bg_grey') ?>">
<? foreach ($this->list as $key=>$item) { ?>
<li><?= $key ?>: <?= q($item) ?></li>
<? } ?>
</ul>
</body>
</html>
You already noticed, we use "$this" in the template, so we need a class context to register variables and a small function to quote content called "q". So let's create a class that makes PHP templates as nice as Smarty templates:

Template.php
class Template { 
function render($php) {
ob_start();
include($php);
return ob_get_clean();
}

function __get($unused) {
return '';
}

static function config($section, $key) {
static $config = null;
if ($config == null) include('core_conf.php');
return isset($config[$section]) ?
$config[$section][$key] : $config[$section];
}

function c($section, $key) {
return self::config($section, $key);
}
}

function q($str) {
return htmlspecialchars($str, ENT_QUOTES);
}

With this small class we can write:
index.php
require 'Template.php';
$tpl = new Template();
$tpl->list = array('item1', 'item2', 'item3');
$tpl->description = 'Hello World!';
echo $tpl->render('main_tpl.php');
And we're done!