Live This site runs Joomla 6.1.2
JoomClub

News, security and craft for the Joomla ecosystem

Development

Find the Top-Level Category of Any Joomla Category

Colour a section by its top-level category, load a different module set per branch, pick a header image by area of the site — all of these need the same thing: given a category, which root section does it belong to? Joomla stores…

Why nested sets make this easy

Every category row carries lft and rgt values. A category contains another when its lft is smaller and its rgt is larger — so all ancestors of a node can be selected in a single condition, without recursion and without a query per level.

Add level = 1 and you get exactly one row: the top-level ancestor.

The helper

use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;

function getRootCategory(int $catid): ?int
{
    static $cache = [];

    if (array_key_exists($catid, $cache)) {
        return $cache[$catid];
    }

    $db    = Factory::getContainer()->get(DatabaseInterface::class);
    $query = $db->getQuery(true)
        ->select($db->quoteName('parent.id'))
        ->from($db->quoteName('#__categories', 'child'))
        ->join(
            'INNER',
            $db->quoteName('#__categories', 'parent'),
            $db->quoteName('parent.lft') . ' <= ' . $db->quoteName('child.lft')
            . ' AND ' . $db->quoteName('parent.rgt') . ' >= ' . $db->quoteName('child.rgt')
        )
        ->where($db->quoteName('child.id') . ' = :catid')
        ->where($db->quoteName('parent.level') . ' = 1')
        ->bind(':catid', $catid, ParameterType::INTEGER);

    return $cache[$catid] = (int) $db->setQuery($query)->loadResult() ?: null;
}

It works for any component that stores its categories in #__categories — contacts, banners, news feeds and most third-party components do, because they reuse Joomla's category manager rather than building their own tree.

Two things worth noticing

Cache per category, not globally. The version of this helper that circulates in older tutorials uses a single static $id without a key: the first call fills it, and every later call returns that same value regardless of which category was asked for. On a page rendering several categories the results are silently wrong. Keying the cache by $catid, as above, fixes it.

Bind the parameter. Casting to int makes the value safe, but binding is what the modern database layer expects and it keeps the query readable. Concatenating raw input into SQL is the habit worth losing regardless of type.

Where the root category is level 1, not 0

Joomla has a hidden ROOT category at level 0 that parents every tree. The top-level categories a site actually uses are level 1 — which is why the condition above is level = 1 and not level = 0. Getting that wrong returns the invisible root for every input, which looks like the query silently failing.

When you do not need a query at all

If you already have the category object from a model, check what it carries: many of Joomla's category objects expose the ancestor path directly, and reading it costs nothing. Reach for this helper when all you have is an ID — inside a module, for example, where no component model has run.