Exceptional Cake

Posted by Felix Geisendörfer, on Oct 21, 2007 - in PHP & CakePHP » Core & Hacking

Hey folks,

sorry for letting you guys wait so long, but here is my promised post on how to use Exceptions in CakePHP. Before you continue reading, be warned that you'll need PHP 5 as well as CakePHP 1.2 for this code to work properly.

First of all. Why did I decide to experiment with exceptions in CakePHP? Well, Object::cakeError() does an ok job at providing me with a way to render some sort of internal error while I'm debug mode. However, I think that is what its really meant for, and its not the way to go for rendering errors to the user directly. Besides you cannot really use it within a static function call, a class that is not a descendant of 'Object', nor do you have any way of "catching" an error thrown this way. All of these things can be addressed by using PHP5s support for custom Exception classes quite elegantly.

But lets look at the code before I explain even further. Put this in /app/error.php:

php
  1.  
  2. uses('error');
  3. /**
  4.  * undocumented class
  5.  *
  6.  * @package default
  7.  * @access public
  8.  */
  9. class AppError extends ErrorHandler{
  10. /**
  11.  * New Exception handler, renders an error view, then quits the application.
  12.  *
  13.  * @param object $Exception AppException object to handle
  14.  * @return void
  15.  * @access public
  16.  */
  17.    static function handleException($Exception) {
  18.       $Exception->render();
  19.       exit;
  20.    }
  21. /**
  22.  * Throws an AppExcpetion if there is no db connection present
  23.  *
  24.  * @return void
  25.  * @access public
  26.  */
  27.    function missingConnection() {
  28.       throw new AppException('db_connect');
  29.    }
  30. }
  31. set_exception_handler(array('AppError', 'handleException'));
  32.  
  33. /**
  34.  * undocumented class
  35.  *
  36.  * @package default
  37.  * @access public
  38.  */
  39. class AppException extends Exception {
  40. /**
  41.  * Details about what caused this Exception
  42.  *
  43.  * @var array
  44.  * @access public
  45.  */
  46.    var $info = null;
  47. /**
  48.  * undocumented function
  49.  *
  50.  * @param mixed $info A string desribing the type of this exception, or an array with information
  51.  * @return void
  52.  * @access public
  53.  */
  54.    function __construct($info = 'unknown') {
  55.       if (!is_array($info)) {
  56.          $info = array('type' => $info);
  57.       }
  58.       $this->info = $info;
  59.    }
  60. /**
  61.  * Renders a view with information about what caused this Exception. $info['type'] is used to determine what
  62.  * view inside of views/exceptions/ is used. The default is 'unknown.ctp'.
  63.  *
  64.  * @return void
  65.  * @access public
  66.  */
  67.    function render() {
  68.       $info = am($this->where(), $this->info);
  69.      
  70.       $Controller = new Controller();
  71.       $Controller->viewPath = 'exceptions';
  72.       $Controller->layout = 'exception';
  73.      
  74.       $Dispatcher = new Dispatcher();
  75.       $Controller->base = $Dispatcher->baseUrl();
  76.       $Controller->webroot = $Dispatcher->webroot;
  77.      
  78.       $Controller->set(compact('info'));
  79.       $View = new View($Controller);
  80.  
  81.       $view = @$info['type'];
  82.       if (!file_exists(VIEWS.'exceptions'.DS.$view.'.ctp')) {
  83.          $view = 'unknown';
  84.       }
  85.      
  86.       header("HTTP/1.0 500 Internal Server Error");
  87.       return $View->render($view);
  88.    }
  89. /**
  90.  * Returns an array describing where this Exception occured
  91.  *
  92.  * @return array
  93.  * @access public
  94.  */
  95.    function where() {
  96.       return array(
  97.          'function' => $this->getClass().'::'.$this->getFunction()
  98.          , 'file' => $this->getFile()
  99.          , 'line' => $this->getLine()
  100.          , 'url' => $this->getUrl()
  101.       );
  102.    }
  103. /**
  104.  * Returns the url where this Exception occured
  105.  *
  106.  * @return string
  107.  * @access public
  108.  */
  109.    function getUrl($full = true) {
  110.       return Router::url(array('full_base' => $full));
  111.    }
  112. /**
  113.  * Returns the class where this Exception occured
  114.  *
  115.  * @return void
  116.  * @access public
  117.  */
  118.    function getClass() {
  119.       $trace = $this->getTrace();
  120.       return $trace[0]['class'];
  121.    }
  122. /**
  123.  * Returns the function where this Exception occured
  124.  *
  125.  * @return void
  126.  * @access public
  127.  */
  128.    function getFunction() {
  129.       $trace = $this->getTrace();
  130.       return $trace[0]['function'];
  131.    }
  132. }
  133.  

You'll also need this in your /app/config/bootstrap.php file:

php
  1. require_once(APP.'error.php');

Now you can do cool stuff like this:

php
  1. function view($id = null) {
  2.    $this->Task->set('id', $id);
  3.    if (!$this->Task->exists()) {
  4.       throw new AppException(array('type' => '404', 'id' => $id));
  5.    }
  6.    // ...
  7. }

Or like this:

php
  1. static function svnVersion() {
  2.    static $version = null;
  3.    if (!is_null($version)) {
  4.       return $version;
  5.    }
  6.    
  7.    $version = trim(shell_exec("svn info ".ROOT." | grep 'Changed Rev' | cut -c 19-"));
  8.    if (empty($version)) {
  9.       throw new AppException('no_working_copy');
  10.    } elseif (!is_int($version) || !($version > 0)) {
  11.       throw new AppException('svn_version');
  12.    }
  13.    return $version;
  14. }

Or just as simple as:

php
  1. function utcTime() {
  2.    $time = strtotime(gmdate('Y-m-d H:i:s'));
  3.    if (!is_numeric($time)) {
  4.       throw new AppException();
  5.    }
  6.    return $time;
  7. }

In either case you'll need a new 'exception.ctp' layout. This layout should be very simple, and ideally work even if no Models could have been loaded or other parts of your system have failed. If you have a dynamic navigation, this means either falling back to a default one, or not displaying anything but a back button.

After you created that you also need a default exception view called 'unknown.ctp'. Mine looks simply like this:

php
  1. <h1><?php echo $this->pageTitle = 'Oops, an internal error occured'; ?></h1>
  2. <p>Sorry, but something must have gone horribly wrong in the internal workings of this application.</p>

For exceptions that are associated with a HTTP response status like '404', I recommend a view like this:

php
  1. <h1><?php echo $this->pageTitle = '404 - Page not found'; ?></h1>
  2. <p>We are sorry, but we could not locate the page you requested on our server.</p>

Alright this is nice ... but you can do even more! Having the unified AppError::handleException function allows you to do fun things like logging your exceptions, or even sending out notification emails to the system administrator. Oh and its also very convenient if you want to catch only certain kinds of Exceptions:

php
  1. try{
  2.    $version = Common::svnVersion();
  3. } catch (AppException $Exception) {
  4.    if ($Exception->info['type'] != 'no_working_copy') {
  5.       AppError::handleException($Exception);
  6.    }
  7.    $version = 'HEAD';
  8. }

One of the things I'm currently trying to do with Exceptions is to build my current application so that it fails fast. By that I mean that I rather have the user see a big fat error message instead of trying to recover from failing functions. Jeff Atwood has an interesting article on this subject which I mostly agree with. However with web applications I feel like we can justify seeing our users see our application crashing hard much more often then with desktop software. That is because its simply much easier to fix the problem for everybody - no software update needed. If you go this path however, please make sure you have rock-solid error reporting form or an e-mail address independent from the server where the app runs on, and mention those in your exception layout.

Anyway, I'm going to periodically make changes to this AppException class and eventually add support to allow people to customize its behavior (like add logging) without having to change the code itself. For now however this should give you some inspiration on how you could leverage some of that yummy PHP5 goodies that a lot of us cake folks sometimes forget about (just b/c cake is PHP4 compatible it doesn't mean our apps have to be!).

Hope some of you find this useful,
-- Felix Geisendörfer aka the_undefined