Custom error handling in CakePHP

Posted by Jad on October 12, 2007

One of CakePHP’s magic is the routing system: router, dispatcher, error handler.

All three are involved in handling every HTTP request made to the application. Some of the invaluable features of this routing system are the default missing controller/action/helper/component/etc. which saves any new comer to the CakePHP community lots of trouble when starting to bake stuff.

When I started looking into the system’s core for how to best handle custom errors, I stumbled on different things that I thought interesting to point out and a couple _bugs_ (or uncleaned legacy lines of code). I also realized that it couldn’t do the basic stuff I needed to handle like logging errors and showing a specific error template for specific errors. So, let’s get to it.

The dispatcher

For what I needed, the magic routing methods were not where I wanted to start, instead, I chose to start with the dispatcher. After all, that’s the node where all the system’s automated redirection happens. Here’s a resume of what happens:

1. Dispatcher::dispatch() gets a call

2. Dispatcher::dispatch() attempts to get a controller instance (the one defined in params which it just parsed)

3. IF no controller object is returned, it tries a weird regex ([\\.]+) which is supposed to match dots (.) on the but Dispatch::__getController() returned value, which, in this case is always null.

4. IF the regex fails (which it will in all cases I tested), it calls Object::cakeError() with a ‘missingController’ method.

So, let’s get back to #3. Normally, if __getController() was returning a string to evaluate AND if the regex is changed not only to check for dots, the 404 error would get called right then and there, which, I believe is what normally should happen in a production environment.

I’ll get back to Object::cakeError() in a bit, but now, let’s also cover when the controller gets loaded but the action or view are not found.

5. Sets missingAction, missingView and privateAction to false.

6. Lists all controller’s methods (Controller, AppController and YourController) and also, in a separate one all the methods defined in Controller only (all considered protected).

7. Checks if the first character in the params action is an underscore, if it is, privateAction turns to true.

8. Checks if the action exists in the list of all defined controller classes. If none is found, missingAction becomes true.

9. Checks if there is a view for the action and if none is found, halts the Controller::autoRender by setting it to false.

After those steps it sets different Controller’s attributes and finally:

10. IF privateAction is true, calls Object::cakeError() with a ‘privateAction’ method

11. Otherwise, it calls Dispatcher::_invoke(), giving it the controller’s object, the params and missingAction’s value

Dispatcher::_invoke() does a pretty simple job:

1. IF missingAction is true, it checks if Controller::scaffold exists, if it does, it loads the Scaffold controller and terminate.

2. IF it’s still a missingAction but no scaffolding defined, it terminates with a call to Object::cakeError() with a ‘missingAction’ method.

There, you have most of it. The missingView, missingComponent, missingHelper, etc. are called in a similar way when either loading components, rendering views, connecting to a database, etc.

cakeError

From Object::cakeError() docBlock:

/**
* Used to report user friendly errors.
* If there is a file app/error.php this file will be loaded
* error.php is the AppError class it should extend ErrorHandler class.
*
* @param string $method Method to be called in the error class (AppError or ErrorHandler classes)
* @param array $messages Message that is to be displayed by the error class
* @return error message
* @access public
*/

So basically, you can extend the ErrorHandler class in /app/error.php with a new AppError class. That’s all we need for example to override missingAction, missingController, etc.

If you don’t create a new AppError class, the ErrorHandler::__construct() will look for an AppController::appError() method - so if you have simple things you want to do where a switch by method case would do the job, you can always do that and skip what’s coming next because it ain’t what I documented.

Oh, and one last thing, you can redirect set any HTTP header code using Controller:redirect(null, $http_code)

Custom ErrorHandler

So far, we’ve seen how ’smart’ the dispatcher can be but what about the error handler? I find the ErrorHandler class bloated with useless code and another bug here. Since I don’t like to just rant but instead get my hands dirty and prove it can be done in a better way, here is my version and some explanations will follow:

class AppError extends Object
{
   /**
    * Cake's core error that should appear when debug > 0
    *
    * @var     array
    * @access  private
    */
   var $_basicErrors = array(
                     'missingAction', 'missingComponentClass', 'missingComponentFile',
                     'missingConnection', 'missingController', 'missingDatabase',
                     'missingHelper', 'missingLayout', 'missingModel', 'missingTable',
                     'missingView', 'privateAction'
                     );
   /**
    * Constructor
    *
    * @param   string      HTTP errors are prefixed with 'error' e.g. error404
    * @param   array       of messages to Controller::set() for view
    * @access  protected
    */
   function __construct($method, $messages)
   {
		parent::__construct();
		static $__previousError = null;

		$this->_method = $method;

		$allow = array('.', '/', '_', ' ', '-', '~');
      if (substr(PHP_OS,0,3) == "WIN")
      {
         $allow = array_merge($allow, array('\', ':') );
      }
		$clean = new Sanitize();
		$messages = $clean->paranoid($messages, $allow);
		if (!class_exists('dispatcher'))
      {
			require CAKE . 'dispatcher.php';
		}
		$this->__dispatch =& new Dispatcher();
		if (!class_exists('appcontroller'))
      {
			loadController(null);
		}
		if ($__previousError != array($method, $messages))
      {
			$__previousError = array($method, $messages);

			$this->controller =& new AppController();
			if (!empty($this->controller->uses))
         {
				$this->controller->constructClasses();
			}
			$this->controller->_initComponents();
			$this->controller->cacheAction = false;
			$this->__dispatch->start($this->controller);

			if (method_exists($this->controller, 'apperror'))
         {
				return $this->controller->appError($method, $messages);
			}
		}
      else
      {
			$this->controller =& new AppController();
			$this->controller->cacheAction = false;
		}
		call_user_func_array(array(&$this, 'basicError'), $messages);
   }
   /**
    *
    *
    */
   function basicError($params)
   {
      $_method = $this->_method;
      if (in_array($this->_method, $this->_basicErrors))
      {
         if (Configure::read() > 0)
         {
            extract(Router::getPaths());
         }
         else
         {
            $this->_method = 'error404';
         }
      }
      extract($params, EXTR_OVERWRITE);

      $method = $this->_method;
      if (!in_array($method, array('missingConnection', 'missingDatabase', 'missingConnection')))
      {
         $this->controller->base = $base;
      }
      $this->controller->webroot = (in_array($method, array('missingController', 'missingAction', 'privateAction'))) ? $webroot : $this->_webroot();
      $this->controller->viewPath ='errors';

      $code = substr($this->_method, 5, 3);
      $method = is_numeric($code) ? 'http' : $this->_method;
      switch($method)
      {
         case 'http':
            $url = (!isset($url)) ? $action : $url;
            $name = (!isset($name)) ? 'Not Found' : $name;
      		$this->controller->set(array('code' => $code,
      										'name' => $name,
      										'message' => $message,
      										'title' => $code . ' ' . $name,
                                    'url' => $url));
            $method = 'error' . $code;
            $this->controller->redirect(null, (int) $code);
            $this->log(sprintf(__('Failed request to %s - Code: %s | Method: %s', true), Dispatcher::uri(), $code, $_method), 'httpd.error');
            break;
         case 'missingController':
            $controllerName = str_replace('Controller', '', $className);
            $this->controller->set(array('controller' => $className,
                                    'controllerName' => $controllerName,
                                    'title' => __('Missing Controller', true)));
            break;
         case 'missingAction':
      		$this->controller->set(array('controller' => $className,
      										'action' => $action,
      										'title' => __('Missing Method in Controller', true)));
            break;
         case 'missingTable':
      		$this->controller->set(array('model' => $className,
      										'table' => $table,
      										'title' => __('Missing Database Table', true)));
            break;
         case 'missingDatabase':
      		$this->controller->set(array('title' => __('Scaffold Missing Database Connection', true)));
      		$method = 'missingScaffolddb';
            break;
         case 'missingView':
      		$this->controller->set(array('controller' => $className,
      										'action' => $action,
      										'file' => $file,
      										'title' => __('Missing View', true)));
            break;
         case 'missingLayout':
      		$this->controller->layout = 'default';
      		$this->controller->set(array('file'  => $file,
      										'title' => __('Missing Layout', true)));
            break;
         case 'missingConnection':
      		$this->controller->set(array('model' => $className,
      										'title' => __('Missing Database Connection', true)));
            break;
         case 'missingHelperFile':
      		$this->controller->set(array('helperClass' => Inflector::camelize($helper) . "Helper",
      										'file' => $file,
      										'title' => __('Missing Helper File', true)));
            break;
         case 'missingHelperClass':
      		$this->controller->set(array('helperClass' => Inflector::camelize($helper) . "Helper",
      										'file' => $file,
      										'title' => __('Missing Helper Class', true)));
            break;
         case 'missingComponentFile':
      		$this->controller->set(array('controller' => $className,
      										'component' => $component,
      										'file' => $file,
      										'title' => __('Missing Component File', true)));
            break;
         case 'missingComponentClass':
      		$this->controller->set(array('controller' => $className,
      										'component' => $component,
      										'file' => $file,
      										'title' => __('Missing Component Class', true)));
            break;
         case 'missingModel':
      		$this->controller->set(array('model' => $className,
      										'title' => __('Missing Model', true)));
            break;
         default:
      		$this->controller->set(array(
      										'name' => $name,
      										'message' => $message,
      										'title' => $code . ' ' . $name));
      } // switch

      $this->controller->render($method);
      exit();
   }
   /**
    * Path to the web root.
    *
    * @return string full web root path
    * @access private
    */
   function _webroot()
   {
		$this->__dispatch->baseUrl();
		return $this->__dispatch->webroot;
	}
}

As you can notice, I used the AppError and instead of extending ErrorHandler, I extended Object just like ErrorHandler does. I even left the __constructor almost unchanged with the exception of a couple added/modified lines. This version fixes the bug that no matter what new http error method you generate, it will always load the template associated with it and not only ‘error404′ where, if you really care about usability, you’d want to add a couple link suggestions depending on the requested URL, a search site or report this error form and any of the other good practices preached by the usability gurus.

Another important thing, it logs every error encountered by users. You’d be surprised, but looking at those logs often helps identifying break points or mishaps that otherwise would be left unnoticed.

And finally, even though I use new lines before and after every bracket (unlike the Cake convention), I like to pride myself with the fact that the code is MUCH shorter with more features.

Use it exactly like you would have done before:

$this->cakeError('error500', array('message' => 'Enter a message here', 'title' => 'Comes right after the error code'));

Hoping this helps some of you, but in any case, I’d love to hear how you handle errors in your application.

Trackbacks

Use this link to trackback from your own site.

Comments

Leave a response

  1. biesbjerg Sat, 13 Oct 2007 19:30:22 EDT

    Nice run through :-)

  2. Aditya Bhatt Thu, 25 Oct 2007 10:17:07 EDT

    Good to know this type of Err message informations.

  3. Charlei Fri, 26 Oct 2007 10:18:46 EDT

    Thanks Jad, That was exactly what I needed right now.
    One question though, do you know if other errors are also invoked and catched by this cakeError object? Like notices or warnings? OR do I have to do that with the set_error_handler() function in PHP? thnx again!

  4. Jad Fri, 26 Oct 2007 19:45:01 EDT

    @all: thanks for dropping by.

    @charlie: No, that would in the Debugger object, part of the core libraries, Debugger::handleError():

    /**
    * Overrides PHP’s default error handling
    *

  5. Charlie Sat, 27 Oct 2007 16:51:42 EDT

    Nice! Thnx! Maybe writing a tutorial or blog entry myself someday seems like a good contribution for the cake community.

Comments