Validation in another controller

By Alex McFadyen aka "drayen"
You have 2 controllers, posts and comments, you want to be able to have a form on a posts view which submits to the comments controller, which does its thing then redirect to referrer - easy! BUT you also want the validation errors (if any) to display on the posts view - but currently they get lost when your redirect. Heres how to get around it!

The problem


Sample posts/view.ctp

View Template:

Download code
<div class="post">
<h2><?php  __('Post');?></h2>

<?php echo $post['Post']['body']?>

<fieldset>
<?php 
echo $form->create('Comment');
echo(
'<legend>'.__('Add comment',true).'<legend>');
echo 
$form->input('title');
echo 
$form->input('body');
echo 
$form->input('author_name');
echo 
$form->input('author_email');
echo 
$form->input('author_url');
echo 
$form->end('submit')
?>
</fieldset>

</div>


I've hit this problem more than once, a nice way around it is to use AJAX to call up and submit the form, just like the bakery does - however ajax is not always an option, so poLK and i have come up with a very nice solution, even if i do say so myself!

The solution


Session to the rescue, after puzzling over this for a while and talking with a few people on #cakephp it seemed that the simplest and quickest solution was going to be to set the validation data into the session from the validating controller (e.g. Comments) and then pull it out later in the viewing controller (e.g. Posts) ready to be displayed.

So thats exactly what we've done, we created function called _persistValidation to simplify getting and setting the data to the session.

Add the following function to your app_controller.php

Controller Class:

Download code <?php 
/**
     * Called with some arguments (name of default model, or model from var $uses),
     * models with invalid data will populate data and validation errors into the session.
     *
     * Called without arguments, it will try to load data and validation errors from session 
     * and attach them to proper models. Also merges $data to $this->data in controller.
     * 
     * @author poLK
     * @author drayen aka Alex McFadyen
     * 
     * Licensed under The MIT License
     * @license            http://www.opensource.org/licenses/mit-license.php The MIT License
     */
    
function _persistValidation() {
        
$args func_get_args();
        
        if (empty(
$args)) {
            if (
$this->Session->check('Validation')) {
                
$validation $this->Session->read('Validation');
                
$this->Session->del('Validation');
                foreach (
$validation as $modelName => $sessData) {
                    if (
$this->name != $sessData['controller']){
                        if (
in_array($modelName$this->modelNames)) {
                            
$Model =& $this->{$modelName};
                        } elseif (
ClassRegistry::isKeySet($modelName)) {
                            
$Model =& ClassRegistry::getObject($modelName);
                        } else {
                            continue;
                        }
        
                        
$Model->data $sessData['data'];
                        
$Model->validationErrors $sessData['validationErrors'];
                        
$this->data Set::merge($sessData['data'],$this->data);
                    }
                }
            }
        } else {
            foreach(
$args as $modelName) {
                if (
in_array($modelName$this->modelNames) && !empty($this->{$modelName}->validationErrors)) {
                        
$this->Session->write('Validation.'.$modelName, array(
                                                        
'controller'            =>    $this->name,
                                                        
'data'                     => $this->{$modelName}->data,
                                                        
'validationErrors'     => $this->{$modelName}->validationErrors
                        
));
                }
            }
        }
    }
?>


We also wanted to make the act of pulling the data out of the session seamless, so no additional code would be needed in the viewing controller (Posts), so were going to pull out the data automatically, using beforeRender().

Add the following function to your viewing controller or app_contoller.php (if your lazy, like all good programmers are, and want it enabled for all controllers).

Controller Class:

Download code <?php 
    
function beforeRender(){
        
$this->_persistValidation();
    }
?>


With that in place, all you need to do is call _persistValidation, from your validating controller (Comments) before you redirect e.g.

Controller Class:

Download code <?php 
    
function add() {
        if (!empty(
$this->data)) {
            
$this->cleanUpFields();
            
            
$this->Comment->create();
            if (
$this->Comment->save($this->data)) {
                
$this->Session->setFlash('The Comment has been saved');
            } else {
                
$this->_persistValidation('Comment');
                
$this->Session->setFlash('The Comment could not be saved. Please correct the errors and try again.');
            }
        }
        
$this->redirect($this->referer(), nulltrue);
    }
?>


Yup, its that simple, just $this->_persistValidation('Comment'); and your done!!

Enjoy



Gotchas


In most situations this should just work, but there are situations where it can cause problems.

Say you have Post->Message and Message->UserTo Message->UserFrom, after a restore, both UserTo and UserFrom (as they are references to one model instance) will be initialized with the correct data, BUT not with array('UserTo'=> etc - instead with array('User' =>

There may be others, please if you find them and or any improvements let me know and i will update the tutorial.

Comments 503

CakePHP team comments Author comments

Comment

1 Another way

I had this same exact problem making my blog about a month ago. I ended up solving it like this: I displayed my post at /posts/read/$id with the comment form at the bottom within a div with id="leaveComment". I set the url of the form to:

'url'=>'/comments/create/'.$this->params['pass'][0].'#leaveComment'

In CommentsController::create() :

    function create($post_id=null) {
        if(!empty($this->data) && $post_id) {
            $this->Post->id = $post_id;
            
            if($this->Post->exists()) {
                $this->Comment->create($this->data);
                
                if($this->Comment->validates()) {
                    $this->Comment->data['Comment']['post_id'] = $post_id;
                    $this->Comment->save();
                    
                    $this->redirect('/posts/read/'.$post_id.'#comments',null,true);
                }
                
                return;
            }
        }
        
        $this->redirect('/',null,true);
    }

So, if data was posted to /comments/create/$id and it validates, then it's saved and the page is redirected back to /posts/read/$id#comments. If it doesn't validate, /comments/create/$id#leaveComment is rendered with the /comments/create.ctp view which contains one line of code:

<?=$this->requestAction('/posts/read/'.$params['pass'][0],array('return'));?>

This renders the /posts/read/$id which is set to display errors as normal. The #leaveComment keeps the page focused on the comment form.
posted Tue, Sep 4th 2007, 16:03 by jaredhoyt

Comment

2 Limitations

Nice solution jaredhoyt,

But what if you have say posts and files, both of which have comments - then you run into trouble (i used a similar method to you until i hit this problem). This solution gets around that problem and more :)

posted Wed, Sep 5th 2007, 02:00 by Alex McFadyen

Comment

3 Hadnt thought of that

drayen,

Good question. Right now I'm only using comments for my posts. However, I'm wondering if there is a way to get the referrer and set() it into the view as the url to be used in requestAction().

edit: I actually tried the above and realized that it would work for one invalidation only. After that, the referring url would be /comments/create/$id ... Unless I set it in the session in the beginning - in which case I might as well just go with yours. I'm going to try the solution below and I think that will work.

Another way might be to add a hidden field onto the comments form itself with the url to be requested.

second edit: okay, so the above worked great. I added this to my comments form on /posts/read/$id:

<?=$form->hidden('Comment.requestUrl',array('value'=>$this->here));?>
$this->here points to the intended url because it is contained within /posts/read/$id or /file/view/$id etc.

In my CommentsController::create(), I added this line right before the return;

    $this->set('requestUrl',$this->data['Comment']['requestUrl']);
    return;

And I changed the /comments/create.ctp to:

    <?=$this->requestAction($requestUrl,array('return'));?>

I don't consider it the most elegant solution only because I hate hidden values for some reason - but it is an alternative (not better ;) solution to using Sessions. I'm going to keep looking at Controller::viewPath and render() to see if can come up with something better.

note: I was worried that the above solution might be vulnerable to form forging, but I don't think it is. requestAction() doesn't accept urls outside of the site. It also checks my Auth/Acl for access to the requested Urls, so I don't have to worry about a forged form accessing a restricted part of the site. But if someone else has any opinions about its security, I'm all ears.
posted Wed, Sep 5th 2007, 11:33 by jaredhoyt

Login to Submit a Comment