Cascading dropdowns in CakePHP 4

Despite my very rusty coding skills, I’ve been working to re-write an old CakePHP 2 application in CakePHP 4. I’m almost done – hurrah! – and while it’s not the prettiest code, it works. The only outstanding issue I’m having is writing the code for cascading dropdowns. Many years ago, I followed this brilliant tutorial, helped by the fact that the author (a real, bonafide developer – unlike me!) shared my frustration in trying to follow others available at the time.

Unfortunately, that doesn’t work anymore with CakePHP 4. Nor do the five others I would have linked to, except new forum users can only include two links in their posts. I’m aware that there’s an AJAX plugin but it’s much too complicated for simpletons like me, and the documentation and example code is also out-of-date for CakePHP 4.

I decided the best option might be to try and ‘upgrade’ the code in this very, very simple example that could be entirely idiot-proof, if only it worked. As such, I’ve spent the last week or so trying to do exactly that – but I’ve hit a wall. My code is currently as follows:

In my Countries controller:

public function initialize(): void {
    parent::initialize();
    $this->loadComponent('RequestHandler');
}

public function select() {
    $countries = $this->Countries->find('list');
    $states = array();
    foreach ($countries as $country_id => $name) {
        $states = $this->Countries->States->findByCountryId($country_id);
        break;
    }
    $this->set(compact('countries', 'states'));
}

public function stateslist() {  
    if (!$this->request->is('ajax')) {
        throw new MethodNotAllowedException();
    }
    $id = $this->request->getQuery('id');
    if (!$id) {
        throw new NotFoundException();
    }
    $states = $this->Countries->States->find('list', array(
        'conditions' => array('country_id' => $id)
    ));
    $this->set(compact('states'));
}

And in my select.php (Countries) template:

<?= $this->Form->create() ?>
<fieldset>
    <legend><?php echo __('Countries and States');?></legend>
    <?php
        $url = $this->Url->build(['controller' => 'countries', 'action' => 'states_list', 'ext' => 'json']);
        $empty = count((array)$states) > 0 ? __('Please Select') : array('0' => __('No States Available'));
        echo $this->Form->control('country_id', array('id' => 'countries', 'rel' => $url, 'empty' => 'Choose Country'));
        echo $this->Form->control('state_id', array('id' => 'states', 'empty' => $empty));
    ?>
</fieldset>
<?= $this->Form->button(__('Submit')) ?>
<?= $this->Form->end() ?>

<script type="text/JavaScript">
    $(document).ready(function(){
        //$("#states").attr("disabled","disabled");
        $("#countries").change(function(){
            //$("#states").attr("disabled","disabled");
            $("#states").html("<option>Please wait...</option>");
            var id = $("#countries option:selected").attr('value');
            $.get("/countries/stateslist", {id:id}, function(data){
                $("#states").removeAttr("disabled");
                $("#states").html(data);
            });
        });
    });
</script>

From what I can tell, the issue appears to be that the request isn’t being processed by the controller. I think it might be because I need to enable routing for the JSON extension, because right now, the request is being passed to the controller, but the controller throws an error because it can’t find a stateslist.php template (whereas, I think, it shouldn’t need one – it should process the AJAX as it is). Of course, there could be (and probably are) other issues with my hacked together code, but I’m tackling one problem at a time here.

Regrettably, I am at a total loss on how to do this in CakePHP 4. There is plenty of documentation on doing this in previous versions, and many answered questions on StackOverflow etc. (that often contradict one another, as so many things have been deprecated/replaced/upgraded over Cake versions). The bigger issue is that the CakePHP 4 cookbook also doesn’t appear to have been updated, because some the examples I try to follow (e.g. on the Routing and Views pages) no longer seem to be applicable.

With that in mind: is this my (only) problem, and could anyone please point me in the direction of how to fix it? A quick Google search shows that cascading/dependent dropdowns in CakePHP seem to have been a huge challenge for many people over the years. It would be ideal if I can get this code to become an idiot-proof example for others using CakePHP 4.

If it’s looking for a template, that’s because it needs to know how to format the output. Does the error message say where it can’t find that file? Knowing the exact path will help to understand what type of output it thinks it’s supposed to be generating, which in turn will help to diagnose exactly where the problem is.

The error is from Cake\View\Exception\MissingTemplateException; “the view for CountriesController::stateslist() was not found.”

I didn’t think it would need a view because the purpose of this controller action is simply to serve data. To try and rectify this, I added:

return $this->response;

to the last line of the “public function stateslist() {” function.

Now I get no error, but the dropdown still doesn’t update. I’m guessing this means there’s an error either in my database queries or in my AJAX – i.e. the data isn’t being requested in the controller function, or is being requested but isn’t being passed back. I’d really appreciate suggestions on where the errors in my code might be.

By using return $this->response;, you’re telling Cake that the response object already has everything it needs. If you haven’t put any output into that (e.g. via withStringBody or the like), then there will be no output. When you don’t return a response object, Cake tries to render a template for you, and the template it will try to use will depend on the request type (e.g. Ajax, JSON, CSV, etc.). I was hoping / expecting that you’d get an error that tells you specifically what file it’s looking for (e.g. src/Template/Countries/stateslist.ctp vs src/Template/Countries/json/stateslist.ctp); that would provide insight into whether your request is being interpreted correctly, and give direction on what exactly you need to do to best remedy the situation.

Thanks so much for the quick reply. Apologies that I wasn’t clear – the missing template exception is for /templates/Countries/stateslist.php.

I can now see (by echo’ing the $id var in the Controller) that the country is being successfully passed to the Countries controller when the user selects it. It seems the database query to get the list of states is working too. So, I think the issue is how to format the output so that Cake renders the correct template and passes the result back.

Presumably, then, you just need to add the specified template and the code in it to loop through the query results and build the HTML that you need in the browser. And also add $this->viewBuilder()->setLayout('ajax'); in your controller; the ajax layout adds no markup to whatever comes from your template, which is definitely the desired result here.

1 Like

Might there be another way of doing this that doesn’t require the specified template? For example, if I add this code to the bottom of the function:

$this->set(compact('states'));
$this->RequestHandler->respondAs('json');
$this->response = $this->response->withType('json');
$this->autoRender = false; 
json_encode($states);
echo json_encode($states);

I do get the desired output (kind of) – the following output is echo’d (as an example, I selected Australia, which has two ‘states’ in the database as an example):

{
    "New South Wales": "New South Wales",
    "Western Australia": "Western Australia"
}

I think the only outstanding problem is that this isn’t in the format required in the AJAX, i.e. this bit:

        $.get("/countries/stateslist", {id:id}, function(data){
        $("#states").removeAttr("disabled");
        $("#states").html(data);

I did it! By some miracle. I can’t thank Zuluru enough for their help! I’m going to fix some other bugs, test it a little more, and then write a step-by-step guide for anyone else who’s battling to get this done.

DO NOT echo json_encode($states);. Controllers should never echo output. It interferes with setting HTTP headers on the response, which are required for cookies, etc. Instead I think you can replace your code above (all six lines, from $this->set up to and including echo) with:

return $this->response
    ->withType('json')
    ->withStringBody(json_encode($states));

Alternately, it should be possible to set things up to automatically recognize and render JSON by using a .json suffix on your URL, but that might require other changes. In such an instance, you’d leave your $this->set in place, and remove all the other lines there, and other code would take care of encoding it and adding it to the response.

@Zuluru – no, I only used echo json_encode($states) when debugging to make sure it was populating $states as expected. In any case, here’s the ‘final’ code for anyone else who might find it helpful. Is it perfect? Probably not. Does it work? Yes.

In Countries controller:

public function select() {
    $countries = $this->Countries->find('list');
    $states = array();
    foreach ($countries as $country_id => $name) {
        $states = $this->Countries->States->findByCountryId($country_id);
        break;
    }
    $this->set(compact('countries', 'states'));
}

public function stateslist() {  
    if ($this->request->is('ajax') && null !== $this->request->getQuery('id')) {
            $states = $this->Countries->States->find('list', array('conditions' => array('country_id' => $this->request->getQuery('id'))));
            $this->set(compact('states'));
    } else {
        return $this->redirect(['action' => 'index']);
    }
}

In /templates/Countries/select.php:

<?= $this->Form->create() ?>
<fieldset>
    <legend><?php echo __('Countries and States');?></legend>
    <?php
        $url = $this->Url->build(['controller' => 'countries', 'action' => 'stateslist', 'ext' => 'json']);
        $empty = count((array)$states) > 0 ? __('Please Select') : array('0' => __('No States Available'));
        echo $this->Form->control('country_id', array('id' => 'countries', 'rel' => $url, 'empty' => 'Choose Country'));
        echo $this->Form->control('state_id', array('id' => 'states', 'empty' => $empty));
    ?>
</fieldset>
<?= $this->Form->button(__('Submit')) ?>
<?= $this->Form->end() ?>

<script type="text/JavaScript">
    $(document).ready(function(){
        $("#states").attr("disabled","disabled");
        $("#countries").change(function(){
            $("#states").attr("disabled","disabled");
            $("#states").html("<option>Please wait...</option>");
            var id = $("#countries option:selected").attr('value');
            $.get("/countries/stateslist/", {id:id}, function(data){
                $("#states").removeAttr("disabled");
                $("#states").html(data);
            });
        });
    });
</script>

In /templates/Countries/statelist.php:

<?php foreach($states as $key => $value): ?>
<option value="<?php echo $key; ?>"><?php echo $value; ?></option>
<?php endforeach; ?>

So you did opt for the template. It’s that or parse JSON to build the HTML on the client side, really.

Hi, I’ve followed the code but it when I select the country from the list, the list of the state is not generated but stuck to “please wait”. How can I solve these issues? thanks

Check your browser console, seems fairly likely that there’s a JavaScript error either in sending the request or receiving the results. And check your server logs, most specifically the Cake error log. There are so many possible causes for this, you need to narrow down the source of the problem.

Thanks for your reply, sir.

I’ve checked the browser console and it shows error: 404 not found.

653

Basically I’ve loaded the jquery in head in the default layout. Is it caused by jquery or others? The log file have no error.

What do your Apache access logs say? Is this request being received by the server? What happens if you try to view http://localhost/countries/stateslist/?id=1 in your browser?

when accessing the http://localhost/countries/stateslist/?id=1 it shows Object not found!

and the apache access log shows:

::1 - - [22/Jun/2020:07:18:06 +0800] “GET /countries/stateslist/?id=1 HTTP/1.1” 404 1223 “http://localhost/cake4/countries/select” “Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36”

in the stateslist, is redirect to the index which will lead to http://localhost/cake4/countries/select

public function stateslist() {  
if ($this->request->is('ajax') && null !== $this->request->getQuery('id')) {
        $states = $this->Countries->States->find('list', array('conditions' => array('country_id' => $this->request->getQuery('id'))));
        $this->set(compact('states'));
} else {
    return $this->redirect(['action' => 'index']);
}

}

then if I changed the js in stateslist.php to /cake4/… it still errors.

    <script type="text/JavaScript">
    $(document).ready(function(){
        $("#states").attr("disabled","disabled");
        $("#countries").change(function(){
            $("#states").attr("disabled","disabled");
            $("#states").html("<option>Please wait...</option>");
            var id = $("#countries option:selected").attr('value');
            $.get("/cake4/countries/stateslist/", {id:id}, function(data){
                $("#states").removeAttr("disabled");
                $("#states").html(data);
            });
        });
    });
</script>

my db table:

countries (id,name)
states (id,name)

Looks like you’re running Cake in a subfolder of your webroot, that generally requires some configuration changes which can be fiddly. I can never remember exactly what needs to be done to solve that, sorry.

Don’t have any idea why it would redirect from .../stateslist to .../select.

You’ll need to sort this all out, one way or another, such that you get something meaningful from that URL, before you can start using that URL in Ajax calls.

Foggies, did you ever manage to get this issue resolved? I am encountering the same issue.

I figured it out. There was some inconsistencies (includes/excluding "s"s) in the names used in the scripts provided above.

I’m still unable to solve the issues. Great to know that you solve the problem. Thank for the hint. Would you mind to share your code? it will be helpful :slight_smile: Thanks