Concrete5 speed boost - streamline your Autonav block
Performance, often measured in loading time, is paramount to a website’s success. We’ve all heard stories about the average user’s goldfish-like attention span online. These stories all hammer on the same warning: don’t make them wait or you’ll lose them. So what better place to start our journey to concrete5 website speed-boosting than the most common component of all: the navigation menu?
The Autonav block is slow
It is a well-known fact that Concrete5's Autonav block is slow.
It’s not bad design it’s the nature of the beast and the more nested your website’s navigation is, the slower it gets. Add sub-levels and sub-sub-levels to your navigation and you’ll add seconds to your site’s loading time. Needless to say when performance is measured in milliseconds, a second is a lot.
Another factor that adds to the slowness is how the Autonav block is used by many. More often than not we keep repeating common but terrible patterns we picked up along the way.
Widely used patterns that slow down your website
There are 2 wide-spread patterns that are sure to give your website performance a serious hit:
- redundant Autonav blocks
- hard-coded Autonav blocks
It is very common to have more than one navigation block on a page. At a minimum, there is a main navigation menu and footer navigation menu.
Sometimes the main navigation menu is split in 2: top-level navigation that appears everywhere and a sub-level navigation that appears on appropriate pages.
Then there are mobile versions of these.
When the mobile version is just the desktop one styled for smaller screens the impact on performance is negligible.
But one pattern that I see popping up, again and again, is to have 2 distinct Autonav blocks: one for mobile navigation and one for desktop. Although they pull the exact same list of links from the database they use different markups and as a result are added to the page separately.
These are all examples of redundant Autonav blocks. Or to be more precise Autonav blocks making redundant and costly calls to the database.
Moreover, 99% of the time (very precise and totally reliable figure based entirely on my vast experience 😏) those blocks are not added to the page by drag-and-drop, they are hard-coded in the page. As a result, they are most likely not cached at all.
That means every time your page loads, it has to call the database again to rebuild the navigation afresh. That’s taking the slow road as opposed to re-using an already generated navigation stashed in the cache.
In summary, we often have several Autonav blocks on a page making the exact same costly calls to the database one after the other and doing it on every page load never taking advantage of caching.
For a more thorough explanation of why hard-coded blocks are bad, you can read this article on concrete5tricks.com.
To be honest, there are reasons Autonav blocks are hard-coded. More often than not it is to stop non-technical website owners accidentally deleting or breaking it.
Still, the negative impact of hard-coded navigation on page speed is not trivial.
If only there was a way to still use hard-coded blocks and take advantage of caching while also getting rid of redundancies… 🤔 Glad you asked!
Taking advantage of caching
So what’s the plan you ask? Very simply, any time an Autonav block loads we’ll either cache the result of its database call or grab and reuse previously cached data.
It’s a little more complex than that but not that much.
Caching in Concrete5, when not taken care of automatically, can be done manually and easily thanks to $app->make('cache/expensive')
.
Caching data is done like this:
// First we need an instance of the Application container so let's grab that
$app = \Concrete\Core\Support\Facade\Application::getFacadeApplication();
// From there we grab an instance of the Cache engine
$expensiveCache = $app->make('cache/expensive');
// We grab a cache item and give it a unique handle so we can call it back later
$dataCache = $expensiveCache->getItem('some_unique_handle');
// We lock so no other reading or writing operation can take place while we work with it
$dataCache->lock();
// We set the data and give it an expiry delay in seconds. If not default is 1 month
$dataCache->set($dataToBeCached)->expiresAfter($timeInSeconds);
// Finally we save our data to the cache
$expensiveCache->save($dataCache);
Getting the data back from the cache is done like this:
// As before we need our application instance
$app = \Concrete\Core\Support\Facade\Application::getFacadeApplication();
// And again we grab our cache item using the same handle as before
$dataCache = $expensiveCache->getItem('some_unique_handle');
// We check that the item already exists and is not expired
if (!$dataCache->isMiss()) {
// Everything's fine so we grab our data from the cache
$dataBackFromCache = $dataCache->get();
}
The data to be cached can be a string, an array, an object, or pretty much anything.
Important: the handle you give your cache item will be used to create a folder on the file system. You could have a handle look like a path Something/Like/This
in which case nested folders will be created. Be careful with that as some systems limit the number of characters allowed in a path.
Creating a caching class
Have a look at the Autonav block’s view.php or any of its templates and you’ll quickly figure out that it always starts by grabbing the navigation items in the view itself by doing $navItems = $controller->getNavItems();
Now if you remember we’re most likely dealing with multiple Autonav Blocks and we want to avoid redundant Database calls. Consequently, we need to work with the Autonav block which loads first on the page.
There are multiple possible scenarios but for the sake of simplicity, I’m going to assume the first one that loads is the main navigation and contains all links.
Our task can be summarized as follow:
- Grab the nav items from our first block’s View
- Cache the nav items
- Fetch the cached nav items from the other blocks
Because we need to use the code in several block instances, let’s create a class.
Create the file application/controllers/autonav_caching_engine.php
and add the following code to it:
<?php namespace Application\Controller;
use Concrete\Core\Controller\Controller;
class AutonavCachingEngine extends Controller
{
// Our code will go here
}
We made our class extend the Core Controller class. It’s a quick and easy way to have access to $this->app
without having to instantiate it ourselves. There are other and fancier probably better ways of doing so but let’s keep it like this for now.
Next, we need to write a method that will grab the nav items from the Autonav block’s controller, cache the data, and return it to the Autonav View to use. Additionally, the method should be able to return already cached data if available.
<?php namespace Application\Controller;
use Concrete\Core\Controller\Controller;
class AutonavCachingEngine extends Controller
{
public function getOrSetCachedAutonav($handle, $autonavController, $expire = 2592000)
{
// Extending the Core Controller we have direct access to $this->app
$expensiveCache = $this->app->make('cache/expensive');
// I want to cache all my Autonav data under a folder named WebsiteAutonav
// Under that folder each set of cached data will be put in a sub-folder
// labelled with the value of $handle
$dataCache = $expensiveCache->getItem('WebsiteAutonav/' . $handle);
// If the data already exists in the cache
// I'm returning that to the Autonav View
if (!$dataCache->isMiss()) {
return $dataCache->get();
}
// Nothing in the cache so let's grab our nav items
// from the Autonav controller
$navItems = $autonavController->getNavItems();
$dataCache->lock();
// Caching our nav items
$expensiveCache->save($dataCache->set($navItems)->expiresAfter($expire));
// Returning the nav items to the Autonav View
return $navItems;
}
}
Now that our class is (mostly) ready let’s use it in the Autonav Block.
Update for concrete5 8.5.4
After upgrading to concrete5 8.5.4 this code started throwing—> the following error Serialization of ‘Closure’ is not allowed
.
It turns out each $navItem contains a Page object which in turn contains a SiteTree object which contains a closure somewhere.
I do need the Page object but I don’t need the SiteTree object in my Autonav so I’m going to simply get rid of it. I’m going to slightly modify the code above like this. Right after grabbing the $navItems I’ll do:
// Nothing in the cache so let's grab our nav items
// from the Autonav controller
$navItems = $autonavController->getNavItems();
foreach ($navItems as $index => $item) {
// after upgrading to 8.5.4 caching the $item started throwing the following error
// Serialization of 'Closure' is not allowed
// It seems siteTree includes a closure somewhere that it didn't use to include
// Since I'm not using it (and I'd be surprised if you did) I'm just making it null
$item->cObj->siteTree = null;
$navItems[$index] = $item;
}
Update for Concrete CMS 9
With Concrete 9 objects are now passed through var_export()
before being cached.
var_export()
expects processed objects to include a __set_state()
method but doesn't check for its availability. So when trying to cache an object you don't control, you're out of luck if it doesn't include that _set_state()
method.
Our $item
objects contain an instance of the core Page class so we're out of luck.
Our strategy moving forward will be to:
- Plan ahead for the page information we need from that object
- Grab them
- Add them to
$item
either as a scalar value, array, or simple object - Remove the Page object from
$item
For the sake of this example, let's say I need 2 attributes:
- 1 text attribute with handle my_text_attribute
- 1 tag attribute with handle my_tag_attribute
I selected a tag attribute on purpose to show a possible caveat to be mindful of. More about that below.
Let's fix the loop from our previous attempt
foreach ($navItems as $index => $item) {
// get the page object
$page = $item->cObj;
// get attribute values we need and add them to our $item object
$item->my_text_attribute = $page->getAttribute('my_text_attribute');
$item->my_tag_attribute = (string)$page->getAttribute('my_tag_attribute');
// We will need the page's path later on so let's grab that as well
// The benefit of getCollectionPath() is it doesn't need to go through the database again. More about that below.
$item->pagePath = $page->getCollectionPath();
// Now we remove the page object befor adding $item back into our final array
$item->cObj = null;
$navItems[$index] = $item;
}
Notice how I converted the tag attribute to a string. That is because otherwise we get another of those objects we can't cache successfully. Converting it to a string makes it a list of tags separated by newline characters.
You'll have to know for each attribute what kind of value getAttribute()
sends back. Sometimes it's a scalar value and sometimes an object (e.g tags or select attributes)
Using our class in the Autonav block
The only aspect of using the code we wrote that requires a little thinking is the handle we want to give our cached data. We’ll discuss that in a few sentences.
But first, let’s create our Autonav Block view.
You can either create a template or override the block’s default View, that’s up to you and your website’s requirements. I’m going to create a template called “Cached Nav”. I create the file
application/blocks/autonav/templates/cached_nav.php with my Autonav block’s View code in it.Let’s look for the line $navItems = $controller->getNavItems();
and replace it with the following code:
// First we instantiate our class
$dataCache = \Concrete\Core\Support\Facade\Application::getFacadeApplication()->make(\Application\Controller\AutonavCachingEngine::class);
// Then we use our class object to grab the nav items we need for our Autonav block.
// I'm using the handle "full_nav" because my block is set to display
// all links on all levels. You can call it anything you want that makes sense
// but no spaces though.
$navItems = $dataCache->getOrSetCachedAutonav("full_nav", $controller);
The choice of your cached data’s handle is important enough to spend a few minutes thinking about it.
Let’s say your first loading Autonav Block only loads top levels links while the second one requires all links. The problem here is the first block doesn’t give you what the second one requires so using its cached data is not going to work. Unless…
In reality, if that’s your situation, nothing is stopping you from loading all the links in the first block. The block view will still show only what you want to show and the data will be loaded and cached ready for the second block to use.
In that situation, any reasonable handle will do. Think “full_nav”, "all_links“, ”all_levels_nav”…
On the other hand, in a situation where you have several Autonav blocks each loading only a subset of links, using a sensible handle name is going to make your life easier. For instance, if 2 blocks need all level 2 links, you could use the handle “level_2_links”.
Not a huge deal but thinking of a consistent naming convention will save you some time down the line and make things clear to anyone involved.
Fixing the current page value
If you’ve been paying attention so far or tested the code you are probably not fully happy with the result.
Your Autonav block’s nav items have 2 values that depend on the current page being viewed. These 2 values are $navItem->isCurrent
and $navItem->inPath
.
The first one indicates if the item is the current page. The second one indicates whether the nav item represents a parent page of the page currently being viewed. Both are commonly used to highlight the current link in the nav.
The problem here is the Autonav block’s data cached on the first load will always show the page it was loaded from as the current page no matter what page you’re on.
We absolutely need to correct that. Fortunately, the cached data is a simple object so modifying it on the fly is quite easy.
Please be mindful of the 2 choices detailed in the middle of the code below. You choice will depend on whether you’re using Concrete 9 or below and if you followed the explanations above.
First, let’s add the following method to our AutonavCachingEngine class:
public function adjustCachedAutonavForPage($item, $currentPage)
{
// if the current page ID is the same as this nav item cID it's the current page
if ($currentPage->getCollectionID() == $item->cID) {
$item->isCurrent = true;
$item->inPath = true;
} else {
// We know this is not the currnt page so we set our values to false
$item->isCurrent = false;
$item->inPath = false;
// We get the path for a current page
$cPath = $currentPage->getCollectionPath();
/**
* If you're using Concrete CMS BEFORE version 9
* and you followed the explanation above for version 8.5.4
* and still have access to cObj use this
*/
// getCollectionLink() requires another database call and some processing
// while getCollectionPath() simply returns the value already loaded
// So we use getCollectionPath() for performance sake
// the same way we did it above for the current page
// $item->cObj is the page object attached to the nav item
$itemPagePath = $item->cObj->getCollectionPath();
/**
* If you're using Concrete CMS version 9
* and you followed the explanation above for version 9
* and don't have access to cObj but have pagePath instead
* use this
*/
$itemPagePath = $item->pagePath;
// if the page in $item is a parent page of $currentPage then $cPath should start with $itemPagePath
// the following code checks for that. I found it on stackoverflow
// https://stackoverflow.com/questions/834303/startswith-and-endswith-functions-in-php
// But I didn't use the accepted answer, I used this answer instead https://stackoverflow.com/a/7168986/2276853
// for performance reasons.
// If the current page is a parent page of the page in $item
// we set the value of inPath to true
if (strncmp($cPath, $itemPagePath, strlen($itemPagePath)) === 0) {
$item->inPath = true;
}
}
return $item;
}
And finally, we need to modify the block’s View again to use this new method. In your View look for the first foreach{}
loop, one that looks like this:
/*** STEP 1 of 2: Determine all CSS classes (only 2 are enabled by default, but you can un-comment other ones or add your own) ***/
foreach ($navItems as $ni) {
$classes = [];
if ($ni->isCurrent) {
//class for the page currently being viewed
$classes[] = 'nav-selected';
}
// rest of the code
}
Add one line of code (potentially two, read the comments) at the beginning of the loop like this:
/*** STEP 1 of 2: Determine all CSS classes (only 2 are enabled by default, but you can un-comment other ones or add your own) ***/
// Many Autonav block templates already grab the current page and put its value in the $c variable
// If it's not the case in your view just add the following line.
// If your view already grabs the current page using the getCurrentPage() method
// but uses a variable other than $c, adjust the code insde the loop accordingly
$c = \Concrete\Core\Page\Page::getCurrentPage();
foreach ($navItems as $ni) {
// First let's take care of adjusting stuff from the cache for the current page
// by using our new method and providing it with the current nav item and the current page
$ni = $dataCache->adjustCachedAutonavForPage($ni, $c);
$classes = [];
if ($ni->isCurrent) {
//class for the page currently being viewed
$classes[] = 'nav-selected';
}
/**
* If you're using Concrete CMS version 9
* and you followed the explanation above for version 9
* you can grab your attribute values like this
*/
$myTextAttributeValue = $ni->my_text_attribute;
$myTagAttributeValue = $ni->my_tag_attribute;
// rest of the code
}
You might be wondering why we added this code here in the view instead of doing it all in the AutonavCacheEngine class? Excellent question, glad you asked 👍 Again it’s for performance reasons. Probably just a micro-improvement but still, every little bit counts.
Since the View is already running that loop twice, I didn’t want to run the same loop a third time in our class so I hooked on an existing one instead.
Taking it even further
One good thing about this technique is that it works for blocks added through concrete5's interface by drag-and-drop or added programmatically by hard-coding them in your site’s code.
But keep in mind that it only caches the results of the database call since this is the biggest performance hog of the Autonav block.
To go even further you want to make sure the output of the block is also cached. Not as big a deal but, again, every little bit counts.
For blocks added normally, your settings will take care of caching. But hard-coded blocks are not cached at all.
To solve that issue you might want to have a look at Advanced Cache, a package by top package developer A3020. This package requires a bit of fiddling with PHP but the docs are great and it’s really not that complicated. If performance means something to you you should definitely give it a try.
Do I really need this?
I’m tempted to say yes, always, but as usual, it probably depends.
If you only have a tiny navigation menu on your website with a half-dozen links and no sub-levels then you probably don’t need it.
If you have only one Autonav block for both desktop and mobile navigation, so no redundancies, you might also not need it, provided your cache settings are fully on.
If in doubt I’d say just use it, it can’t hurt.
Where to go from here?
You probably noticed we’ve been using something called the “Expensive Cache”. It’s not the only one and if you want to find out more have a look at concrete5's help page on caching.
Concrete5 often gets a bad reputation for its lack of documentation. Frankly, I disagree. The documentation is getting pretty vast and is full of useful information. Yes, it’s a work in progress but nowhere near as bad as I sometimes hear. I urge you to go through it, you’ll see what I mean.