
When EXT:seo is installed, TYPO3 renders hreflang and canonical tags for you. Most of the time the default output is exactly right. But there is always one project where it is not: a language which is online but not ready for search engines, a partner site on another system that should appear in the hreflang list, or a filter parameter that should never end up in the canonical URL.
In older TYPO3 versions people solved such things with TypoScript tricks, XCLASSes or by switching off the core tags and printing their own. The clean way is two PSR-14 events (both exist since TYPO3 v10), and since v13 you register the listeners with the #[AsEventListener] attribute. No hook arrays, no Services.yaml tags needed.
The two events
TYPO3CMSFrontendEventModifyHrefLangTagsEventis dispatched by the frontendRequestHandler. The core listenerHrefLangGeneratorfrom EXT:seo fills it, using the identifiertypo3-seo/hreflangGenerator. Your listener getsgetHrefLangs(),setHrefLangs(),addHrefLang()and the request.TYPO3CMSSeoEventModifyUrlForCanonicalTagEventis dispatched byCanonicalGeneratorjust before the canonical tag is built. It hasgetUrl(),setUrl(),getRequest(),getPage()andgetCanonicalGenerationDisabledException().
One detail is easy to miss. Since v13.0 (Breaking-99807) the canonical event is dispatched after the core has done its work, and it is also dispatched when canonical generation is disabled, for example because the page has no_index set. So getUrl() can already contain a URL, or it can be empty for a good reason. Your listener must check this, otherwise you add a canonical to a page that should not have one.
I checked the class signatures in TYPO3 14.3. The canonical event got its current shape in v13.0 (Feature-99807), so the code below is written for 13.4 and 14.3.
Step 1: adjust the hreflang list
Example requirement: the site has an English language which editors are still filling. It is reachable, but hreflang should not advertise it yet. At the same time the organisation has a French site on a different system, and that one should be listed for the start page.
<?php
declare(strict_types=1);
namespace VendorSitepackageEventListener;
use TYPO3CMSCoreAttributeAsEventListener;
use TYPO3CMSFrontendEventModifyHrefLangTagsEvent;
#[AsEventListener(
identifier: 'sitepackage/hreflang',
after: 'typo3-seo/hreflangGenerator',
)]
final readonly class AdjustHrefLangTags
{
private const HIDDEN_LANGUAGES = ['en-GB'];
public function __invoke(ModifyHrefLangTagsEvent $event): void
{
$hrefLangs = $event->getHrefLangs();
foreach (self::HIDDEN_LANGUAGES as $code) {
unset($hrefLangs[$code]);
}
$pageId = $event->getRequest()->getAttribute('frontend.page.information')?->getId();
if ($pageId === 1) {
$hrefLangs['fr-FR'] = 'https://fr.example.org/';
}
// x-default only makes sense when there is still more than one language
if (count($hrefLangs) < 3) {
unset($hrefLangs['x-default']);
}
$event->setHrefLangs($hrefLangs);
}
}The after argument is the important part. Without it, your listener may run before the core generator, and then there is nothing to remove yet. The core only prints the tags when the final array has more than one entry, so if you remove too much, the tags disappear completely. That is correct behaviour, not a bug.
A small warning: hardcoding page ids like I did above is fine for a blog example, but in a real project read them from site settings. Uids change when a tree is copied or content is imported again, and such constants are the first thing that breaks.
Step 2: clean the canonical URL
Second example: a list page has filter parameters like ?tx_events_list[category]=3. The core fallback builds the canonical from the current page link including the cHash-relevant parameters, so every filtered view gets its own canonical. Say you want all filtered views to point to the plain list page instead.
<?php
declare(strict_types=1);
namespace VendorSitepackageEventListener;
use TYPO3CMSCoreAttributeAsEventListener;
use TYPO3CMSCoreHttpUri;
use TYPO3CMSSeoEventModifyUrlForCanonicalTagEvent;
#[AsEventListener(identifier: 'sitepackage/canonical')]
final readonly class StripFilterFromCanonical
{
public function __invoke(ModifyUrlForCanonicalTagEvent $event): void
{
// Disabled by TypoScript (disableCanonical) or by no_index: respect that
if ($event->getCanonicalGenerationDisabledException() !== null) {
return;
}
$url = $event->getUrl();
if ($url === '' || !str_contains(urldecode($url), 'tx_events_list[')) {
return;
}
$event->setUrl((string)(new Uri($url))->withQuery(''));
}
}An explicit canonical which an editor sets in the page properties wins in the core before the event, and it will not contain the filter parameter, so this listener leaves it alone. If you need more page data, getPage() gives you a TYPO3CMSCoreDomainPage record object. Use has() before get() on it, because get() throws an exception for a field that is not there.
Step 3: registration
Nothing more. With autoconfigure: true in your Configuration/Services.yaml (which every modern sitepackage has), the attribute registers the listener. You can check the result in the backend module System → Configuration → Event Listeners (PSR-14). After adding a new listener, flush the caches once: ddev typo3 cache:flush.
Step 4: a functional test that reads the rendered tags
Event listeners are small, so people often skip tests. But SEO tags break silently: nobody sees them in the browser. A functional test with typo3/testing-framework (version 9 supports both v13 and v14) renders a real page and checks the head. Here for the hreflang listener on the start page:
<?php
declare(strict_types=1);
namespace VendorSitepackageTestsFunctional;
use PHPUnitFrameworkAttributesTest;
use TYPO3CMSCoreConfigurationSiteWriter;
use TYPO3TestingFrameworkCoreFunctionalFrameworkFrontendInternalRequest;
use TYPO3TestingFrameworkCoreFunctionalFunctionalTestCase;
final class HrefLangTest extends FunctionalTestCase
{
protected array $coreExtensionsToLoad = ['seo'];
protected array $testExtensionsToLoad = ['vendor/sitepackage'];
protected function setUp(): void
{
parent::setUp();
// root page uid 1 and its English translation
$this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
$this->setUpFrontendRootPage(1, ['EXT:sitepackage/Tests/Functional/Fixtures/page.typoscript']);
$this->get(SiteWriter::class)->write('test', [
'rootPageId' => 1,
'base' => 'https://www.example.org/',
'languages' => [
['languageId' => 0, 'title' => 'Deutsch', 'locale' => 'de_DE.UTF-8', 'base' => '/', 'hreflang' => 'de-DE'],
['languageId' => 1, 'title' => 'English', 'locale' => 'en_GB.UTF-8', 'base' => '/en/', 'hreflang' => 'en-GB'],
],
]);
}
#[Test]
public function startPageListsPartnerSiteButNotHiddenLanguage(): void
{
$response = $this->executeFrontendSubRequest(new InternalRequest('https://www.example.org/'));
$html = (string)$response->getBody();
self::assertStringContainsString('hreflang="fr-FR" href="https://fr.example.org/"', $html);
self::assertStringContainsString('hreflang="de-DE"', $html);
self::assertStringNotContainsString('hreflang="en-GB"', $html);
}
}The TypoScript fixture only needs a minimal page = PAGE with some output. Run it in DDEV, for example with ddev exec typo3DatabaseDriver=pdo_sqlite vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml. For the canonical listener a request with filter parameters needs a valid cHash, so I find a plain unit test easier there: create the event with a URL, call the listener, compare getUrl().
What I learned
- Always set
afterfor the hreflang listener, the core generator must run first. - Respect
getCanonicalGenerationDisabledException();no_indexpages should stay without canonical. - Do not fight an explicit canonical from the editor. The core respects it, and it also leaves a translation out of the hreflang list when that translation has its own canonical.
- Test the rendered HTML, not only the listener class.
If this post helped you, I will be really happy. Feedbacks are the champion’s breakfast, so drop a comment if your project needed a different hreflang trick.