Skip to main navigation Skip to main content Skip to page footer

How do I build a glossary with alphabetical grouping?

Do you want to create an A–Z glossary navigation using categories, generating speaking URLs like /letter/term and a correct sitemap.xml, even though your glossary stores categories via N:M (MM table)? Then you've come to the right place!

As a result, we would like to have a glossary like the one on our website:

The URLs in the sitemap.xml should look like this:

Prerequisites

  • TYPO3 version 11–13
  • EXT:seo must be installed for speaking URLs
  • EXT:glossaries for the Extbase plugin for list + details

Attention:

If a glossary entry has multiple categories, /letter/term is ambiguous. For A–Z, you should enforce exactly 1 category per entry (TCA/Editor-UX), otherwise the sitemap/link building will be inconsistent.

Alphabet tabs: Cleanly assigning entries to letters

Procedure

  • Create 26 categories AZ (title + slug = az)
  • Each glossary entry gets exactly one of these categories (its starting letter)

TCA: Enforce "Exactly one category" (recommended)

In your site package, set in the file Configuration/TCA/Overrides/tx_glossaries_domain_model_glossary.php:

// Reduce assigned categories to exactly one!
$GLOBALS['TCA']['tx_glossaries_domain_model_glossary']['columns']['categories']['config']['maxitems'] = 1;

Attention:

After the adjustment, do not forget to clear the system cache!

URL structure via RouteEnhancer

Goal

The goal is to have a URL like /category_slug/glossary_slug for the detail page.

  • List: .../glossary
  • Detail: .../glossary/r/rte

Example: Site-Config RouteEnhancer

config/sites/<siteIdentifier>/config.yaml

routeEnhancers:
    GlossariesPlugin:
        type: Extbase
        limitToPages:
            - 123
        extension: Glossaries
        plugin: Glossary
        routes:
            -
                routePath: '/{category_slug}'
                _controller: 'Glossary::list'
                _arguments:
                    category_slug: selectedCategory
            -
                routePath: '/{category_slug}/{glossary_slug}'
                _controller: 'Glossary::show'
                _arguments:
                    category_slug: selectedCategory
                    glossary_slug: glossary
        defaultController: 'Glossary::list'
        aspects:
            category_slug:
                type: PersistedAliasMapper
                tableName: tx_glossaries_domain_model_glossarycategory
                routeFieldName: slug
                routeValuePrefix: /
            glossary_slug:
                type: PersistedAliasMapper
                tableName: tx_glossaries_domain_model_glossary
                routeFieldName: slug
                routeValuePrefix: /

Note:

This only works stably if selectedCategory really contains the UID of the assigned category (or is cleanly resolvable via Aspect). This is exactly where the standard sitemap often fails with MM fields.

Why does the sitemap.xml always show "a" as the category in the URL?

The standard provider TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider does not reliably fill fieldToParameterMap for N:M fields because the concrete, sorted relation from the MM table is not automatically resolved in the record field (categories).

Effect: In fieldToParameterMap, the number of assigned relations of the N:M relation is used, which in our case is 1 because we only allow exactly one category. In our example, the category with UID 1 happens to be category A – therefore the sitemap always generates URLs with /a/....

Solution: Custom SitemapDataProvider for the "first" category from the MM table

Principle

  • Before the URL build:
    • Get the first category UID from the MM table (according to sorting_foreign)
    • Set $record['categories'] = <uid>
  • Then continue using the standard mechanism, so that your fieldToParameterMap + RouteEnhancer take effect.

Example Implementation (extended Records Provider)

This XmlSitemap DataProvider is also included in EXT:glossaries in Classes/XmlSitemap/RecordsXmlSitemapDataProvider.php.

<?php

declare(strict_types=1);

namespace CodingMs\Glossaries\XmlSitemap;

use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider as ParentProvider;

final class RecordsXmlSitemapDataProvider extends ParentProvider
{
    /** @var array<int,int> Cache: glossaryUid => firstCategoryUid */
    private array $firstCategoryUidCache = [];

    protected function defineUrl(array $data): array
    {
        $record = $data['data'] ?? null;
        if (!is_array($record) || empty($record['uid'])) {
            return parent::defineUrl($data);
        }

        $glossaryUid = (int)$record['uid'];
        // Only fix if the DB field is not already a usable UID
        // (with MM "count" behaviour this is usually 1 for all records, which is wrong)
        $firstCategoryUid = $this->resolveFirstCategoryUid($glossaryUid);
        if ($firstCategoryUid > 0) {
            // Make fieldToParameterMap pick the correct category UID
            $record['categories'] = $firstCategoryUid;
            $data['data'] = $record;
        }
        return parent::defineUrl($data);
    }

    private function resolveFirstCategoryUid(int $glossaryUid): int
    {
        if (isset($this->firstCategoryUidCache[$glossaryUid])) {
            return $this->firstCategoryUidCache[$glossaryUid];
        }
        $queryBuilder = $this->getQueryBuilder('tx_glossaries_glossary_glossarycategory_mm');
        $row = $queryBuilder
            ->select('uid_foreign')
            ->from('tx_glossaries_glossary_glossarycategory_mm')
            ->where(
                $queryBuilder->expr()->eq(
                    'uid_local',
                    $queryBuilder->createNamedParameter($glossaryUid, ParameterType::INTEGER)
                )
            )
            ->orderBy('sorting_foreign', 'ASC')
            ->addOrderBy('uid_foreign', 'ASC')
            ->setMaxResults(1)
            ->executeQuery()
            ->fetchAssociative();
        $firstUid = (int)($row['uid_foreign'] ?? 0);
        return $this->firstCategoryUidCache[$glossaryUid] = $firstUid;
    }

    private function getQueryBuilder(string $table): QueryBuilder
    {
        return GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
    }
}

TypoScript: Switch SEO sitemap to your provider

You only replace the provider with CodingMs\Glossaries\XmlSitemap\RecordsXmlSitemapDataProvider – the rest can remain.

Configuration/TypoScript/setup.typoscript

plugin.tx_seo.config.xmlSitemap.sitemaps.glossaries {
    provider = CodingMs\Glossaries\XmlSitemap\RecordsXmlSitemapDataProvider
    config {
        table = tx_glossaries_domain_model_glossary
        sortField = title
        lastModifiedField = tstamp
        recursive = 1
        # Storage location of the records
        pid = 1200
        url {
            # PageID of the detail page
            pageId = 2038
            fieldToParameterMap {
                uid = tx_glossaries_glossary[glossary]
                categories = tx_glossaries_glossary[selectedCategory]
            }
            additionalGetParameters {
                tx_glossaries_glossary.controller = Glossary
                tx_glossaries_glossary.action = show
            }
        }
    }
}

Note:

The additionalGetParameters must match your plugin namespace (tx_<extkey>_<plugin>). If your plugin has a different name, you must adjust this.