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

Print this Post | Digg This | Stumble It | Delicious

12 Comments

JadB on Oct 21, 2007:

Thanks for sharing!

I haven't worked with exceptions yet, but that definitely gives a good start. I also approached the error handling in cake but using a much simpler way - you really got me thinking now. Haven't really implemented my solution across the app yet, so I will try this one and see which one I am comfortable with best. Thanks again.

PHPDeveloper.org on Oct 22, 2007:

Felix Geisendorfer's Blog: Exceptional Cake...

...

[...] Felix Geisendorfer has posted a new blog entry he’s been promising for a while now - a look at using Exceptions in a CakePHP application: 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. […] [All of] these things can be addressed by using PHP5s support for custom Exception classes quite elegantly. [...]

[...] if you liked my last post on how to use PHP5 Exceptions in CakePHP, then here is a little addition to it: [...]

Yevgeny on Oct 24, 2007:

I play with you solution and got one problem.
Some vendor application use exceptions for interract. In this case we should not handle Exceptions that does not instance of AppException.
So need to change AppError::handleException to process only such Exception that is instance of AppException.
But how to rethrow unknow types exceptions i dont know (different application use many type of Exception class child).

Felix Geisendörfer on Oct 24, 2007:

Yevgeny: Good point, I'll try to add this. But I'm not sure how to re-throw non AppExceptions either. Anybody?

nao on Oct 25, 2007:

@Yevgeny and @Felix

"You can even rethrow exceptions from nested try/catch-blocks, in case you'd like to pass an Exception on to a place you have more control over its handling."

http://www.andreashalter.ch/phpug/20040115/3.html

nao on Oct 25, 2007:

you can do something like that (not tested) :http://bin.cakephp.org/view/428473160

nao on Oct 25, 2007:

Last link don't work : try this : http://bin.cakephp.org/view/1799720146

Yevgeny on Oct 26, 2007:

@NAO:
I start from such tests. You right rethrow work well inside catch but php create new Exception object in we call throw from rethrowException function. In this case we lost all info about exception.

Scott Martin on Jul 08, 2008:

I think you need and echo or print statement on line 18 of your error.php file. It took me a while to figure out why nothing was being sent to the browser.

Tim Koschützki on Jul 18, 2008:

There have been made a few changes to this. For example one would need to call AppController::beforeRender() to push any stuff to the view that would also display for the exception layout. Think of an exception layout that still makes use of a left column or so.

We might re-release this sometime.

Add a comment