Dynamic menus without requestAction() in CakePHP 1.2
You’ve probably heard time and time again (especially in a few recent and popular blog posts) that using requestAction() is, generally speaking, considered to be a “bad practice”, “last resort”, “hackish” way of doing things in cake.
Let’s consider one common use to see if we can achieve our goals without using requestAction()…
We have a Company model and our goal is to build a navigational menu consisting of all the companies in our table. We would create an element called ‘company_nav.ctp’ and somehow load the relevant data from our Company model.
The first, obvious, approach is to use requsestAction() in an element, to get the required data. This, however, is something we would like to avoid.
The approach I offer below relies on cache, or cached model’s data to be precise…
We can agree that our menu or navigation would only change if a company has been added, modified or deleted. Therefore we use our Company model’s callback methods such as afterSave() and afterDelete() to properly cache the required data for the menu.
So, assuming we have a Company model, we can add the following to handle the data caching:
function afterSave() {
$this->_cacheNav();
}
function afterDelete() {
$this->_cacheNav();
}
function _cacheNav() {
$companies = $this->find('list');
Cache::config(null, array('engine'=>'File', 'path'=>CACHE));
Cache::write('companies', $companies, array('duration'=>7200, 'config'=>null));
}
The code is pretty simple, but let me explain it just a little…
We rely on afterSave() and afterDelete() to call our custom method _cacheNav(), which handles the actual caching of the Company data.
In this case we only need Company.name and Company.id for our menu, so using find(‘list’) works perfectly well for that.
We then call Cache::config() to ensure that we write to the correct location (the constant CACHE is defined for us by cake core) and that we use the correct storage engine (I use File, but you can use any supported engine). I found that specifying the ‘path’ is a good idea, since Cache can write to different locations depending on the context, therefore it’s best to be explicit about it.
Alright, now that our data is cached, we can easily build our element (company_nav.ctp):
<?php
Cache::config(null, array('engine'=>'File', 'path'=>CACHE));
$companyData = Cache::read('companies');
if(!empty($companyData)) {
foreach($companyData as $key => $value) {
echo '<div>'.$html->link($value, array('action'=>'view', $key)).'</div>';
}
}
?>
I think the above should be pretty much self-explanatory, we are simply reading the data we’ve cached earlier in our model and building a menu of links.
Again, I prefer to specify the ‘path’ to ensure we attempting to read from the correct cache path.
Before we finish up, I wanted to point out a few important issues:
1. The duration of the cache is limited
Of course you can increase it to some unrealistically large value to cache forever, or you could come up with some fall-back mechanism (i.e. the good ol’ requestAction()) if cache data is not available.
At any rate, this is just something to be aware of and it can be easily fine-tuned to fit your specific needs.
2. Breaking of MVC (?)
In theory the view (element) should not access the data directly, and I’m not 100% sure if using Model’s cached data is somehow an exception to this rule. Looking at the benefits, however, I feel that this approach is justified. Cache is accessible to all of our application objects, therefore we could at least say that we are not breaking the rules, but rather bending them slightly.
This is, BY FAR, the best way I´ve read. Teknoid, as always, you show that you have God´s cellphone. Thanks!
@Martin Bavio
Heh, never heard that saying before ;)
Thanks.
Very cunning! :-)
Just a thought: You might want to include afterEdit too in case a user changes a company name.
afterEdit doesn’t exist.
@teknoid you might want to consider putting your call to _cacheNav, or the logic directly in _clearCache – which is automatically called.
i.e. complete replacement for first snippet:
function _clearCache() {
$companies = $this->find(‘list’);
Cache::config(null, array(‘engine’=>’File’, ‘path’=>CACHE));
Cache::write(‘companies’, $companies, array(‘duration’=>7200, ‘config’=>null));
}
Great solution to a common problem. I looked over the other answers offered on blogs lately and this makes a lot more sense to me and doesn’t seem to hack around the problem.
Thanks for coming up with this :)
Really nice solution! I like the use of cache. I did this in the app_controller beforeRender() and assign a template var and use an element. But this is also nice.
FWIW – accessing data directly in views is not breaking MVC
@Richard@Home
Thanks.
re: afterEdit()… that’s actually handled by afterSave() as well.
@primeminister
That’s a good solution, the only issue is that when you need the menu on some pages and not the others, it seems like you need to add a bit of logic to figure out when to make the call and when to not.
@AD7six
Thanks, good to know.
@Tarique Sani
Well, I wasn’t quite sure, but now I feel even better about it :)
@David Boyer
You’re welcome
[...] Dynamic menus without requestAction() in CakePHP 1.2 Uses model callbacks to cache the data. Very cunning. (tags: cakephp requestAction model callback) Posted by Richard@Home Filed in 15 [...]
I think the use of caching sets this approach apart from the others that have been suggested. That and you have kept the logic in the model which I am a fan of.
You mention backing up the cache with a requestAction() just in case the cache expires. If you are trying to avoid requestAction() wouldn’t it more prudent to make the cache last much longer? Like forever? As you mentioned you only need to refresh it whenever the companies change. So caching forever is an option.
@Mark Story
Thanks, good points.
I kinda just threw out requsestAction() fallback as food for thought… also I wasn’t sure if cake has some built-in setting to cache something forever.
in proposed task better to use requestAction in view and caching of menu element.
In cacheNav you need to remove element cache file.
This will give you
1. better speed during menu rendering. You will execute code slow first time when requestAction called, but immediatelly in all other requests.
2. your solution has problems if cashe file will deleted. This will not happen if you will use requestAction solution
@Skiedr
Using requestAction() is always slow and never recommended, so the whole point of this article was to look at alternatives to requestAction().
As I mention you should have some fall-back in place, if cache is not available.
eh, simply mov ethe cache-reading-code to the controller layer (controller or component), and you don’t need to worry about breaking the mvc-pattern!? ;)
@jacknirons
Well, the code has to be in an element, since we don’t know when we need to read that data (i.e. in which controller/action). That being said, I think reading cache is fine, since it is available to my view as well.
Howdy folks!
After a few tries I did not get the above method to work. For no apparent reason the cache got deleted since the duration was not set properly in the cache file.
Instead I tried to use another method which worked fine:
My solution:
I have moved the duration to the Cache::config call and named it.
‘File’, ‘path’=>CACHE, ‘duration’=> ‘1 week’));
Cache::write(‘newsFrontpage’, $this->getFrontpage(), array(‘config’=> ‘newsCache’));
}
?>
Hope if it helps anyone.
Regards.
Somehow my code did not come through as supposed.
Another try:
function _cacheNews () {
Cache::config(‘newsCache’, array(‘engine’=>’File’, ‘path’=>CACHE, ‘duration’=> ‘1 week’));
Cache::write(‘newsFrontpage’, $this->getFrontpage(), array(‘config’=> ‘newsCache’));
}
My bad tho, forgot to add something:
You should also have to use the same Cache::config in your view.
Cache::config(’newsCache’, array(’engine’=>’File’, ‘path’=>CACHE, ‘duration’=> ‘1 week’));
@WalkingSoul
Well, I’m glad you got it sorted out ;)
Correct me if I’m wrong, but if you update _cacheNav to return $companies, you could override the find method like this:
// Get the cached version of the full list whenever possible
function find($findType, $arguments = NULL) {
// Ideally, use the cached version of the website contents list
if ($findType == ‘list’ && $arguments == NULL) {
if ($list = Cache::read(‘website_contents_list’)) {
return $list; // Read from the cached list
} else {
return $this->_cacheNav(); // Write to the cached list
}
}
// Fall back on doing it the proper way
return parent::find($findType, $arguments);
}
@Zoe Blade
Definitely a sound improvement, thank you for sharing it.
Hi,
This may sound silly, but still,how do you set the cache, first time ??
Mohd:
Cache::write(‘companies’, $companies); is the line that writes the contents of the variable $companies to the cache. Because it’s called from within the afterSave() and afterDelete() methods, the cache is automatically set whenever you change the table in the database at all.
@Zoe, Mohd Amjed
Thanks for putting it a lot more eloquently than I would’ve.
(Excellent playlist, btw… but I’m surpised you don’t have scsi-9 (no, not the computer stuff, the producer) ;)
@Teknoid
I was relatively new to CakePHP so I was accomplishing this task setting a layout variable (~_for_loyout) at AppController::beforeFilter() and reading it in my dynamic element. I changed it to the method that you are describing, but I have a question. I added a piece of code in my AppController::beforeFilter() to check if the cache variable is set and if it’s not set then call Category->_cacheNav()… It works perfectly, but Is this a good approach? or am I doing something wrong?
Thanks a lot for this nice post!
@Jorge Pedret
Sounds good to me.
Also check out the the comments by Zoe, I think it’s a really nice approach to override the find() and include caching right there.
Also, I have a recent post about using memcached to accomplish a similar situation, with a bit more “elegance”, I guess…
At any rate, this was a simple exercise to try and avoid requestAction() with cached elements, but I must digress there really isn’t anything wrong with using it in that specific scenario.
Just trying to bake the cake up-side-down a little ;)