---
title: "Matching languages between sites with locales"
manual: "TYPO3 Explained"
version: "main"
permalink: "https://docs.typo3.org/permalink/t3coreapi:extbase-cross-site-locales@main"
source: "ExtensionArchitecture/Extbase/CrossSiteAccess/Locales.rst"
rendered: "2026-09-26T10:15:30+00:00"
---

# Matching languages between sites with locales {#extbase-cross-site-locales}

Pointing a query at another site's storage folder settles which pages are read.
It does not settle which language, and that is where cross-site reading most
often goes quietly wrong: the query succeeds, returns records, and shows them
in the wrong language.

## Why language UIDs cannot be reused {#extbase-cross-site-locales-uids}

A language UID is a number assigned inside one site configuration. It carries
no meaning outside it. Two sites offering the same three languages may number
them differently, and nothing in TYPO3 keeps them aligned.

Consider a shared conference folder, located in the main site and read by the
country site as well:

| Language UID | Main site | Country site |
| --- | --- | --- |
| `0` | English | Polish |
| `1` | Polish | English |
| `2` | Italian | Italian |

The records in the folder carry one `sys_language_uid` each. A visitor
browsing the country site in Polish arrives with language UID `0`, and a query
made with that UID returns the records stored as UID `0` — the English ones.
No error is raised, because from the database's point of view nothing is wrong.

> [!WARNING]
> This failure is silent and looks like a translation problem rather than a
> configuration one. The records are real, the query is valid, and the only
> symptom is that an unexpected set of languages came back.

## The locale is the stable identifier {#extbase-cross-site-locales-join-key}

What *probably is* stable across sites is the locale. A site language for
Italian is `it-IT` in every site that offers Italian, whatever UID it was given
there. The locale is set in the site configuration, is meaningful outside the
site that declares it, and is exactly what two site configurations have in
common. If the locales are really the same across the sites is responsibility
of the integrator, so verify the match before relying on it.

So resolve the language by matching locales, and use the matched language's own
UID for the query:

1.  Take the `\TYPO3\CMS\Core\Site\Entity\SiteLanguage` the visitor
    is browsing, from the site being rendered.
1.  Read its locale.
1.  Find the language of the *storage* site with the same locale.
1.  Query with that language.

**EXT:my_extension/Classes/Service/SharedStorageResolver.php**

```php
<?php

namespace MyVendor\MyExtension\Service;

use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;

/**
 * Resolves the pages and the language to use when reading records that belong
 * to another site.
 */
class SharedStorageResolver
{
  public function __construct(
    protected readonly SiteFinder $siteFinder,
  ) {}

  /**
   * The site the records belong to, identified by the site identifier
   */
  public function resolveStorageSite(string $siteIdentifier): Site
  {
    return $this->siteFinder->getSiteByIdentifier($siteIdentifier);
  }

  /**
   * Matches the language the visitor is browsing against the languages of the
   * storage site, using the locale as the join key.
   *
   * Returns null when the storage site does not offer the language at all,
   * which is a case the caller has to decide about — see the fallback
   * section of this chapter.
   */
  public function resolveStorageLanguage(
    Site $storageSite,
    SiteLanguage $currentLanguage,
  ): ?SiteLanguage {
    $wanted = (string)$currentLanguage->getLocale();

    foreach ($storageSite->getLanguages() as $candidate) {
      if ((string)$candidate->getLocale() === $wanted) {
        return $candidate;
      }
    }

    return null;
  }
}

```

`getLanguages()` returns the languages of the storage site that are
enabled for frontend output, which is the right set to match against for a
plugin. `getAllLanguages()` includes disabled ones and suits a backend
module that deliberately shows them.

> [!IMPORTANT]
> It is not advisable to build a hand-maintained mapping array between the
> sites' language UIDs or to keep the mapping in a site setting. Both encode
> a relationship that the site configurations already state, and both go
> stale the moment somebody adds a language — silently, and in exactly the
> way described above. The locale is the join key; read it at runtime.

## Building the query from the matched language {#extbase-cross-site-locales-aspect}

The matched language is turned into a language aspect with
`\TYPO3\CMS\Core\Context\LanguageAspectFactory::createFromSiteLanguage()`,
the same call the frontend uses. The aspect then carries the storage site's
language UID *and* the storage site's translation behavior:

**EXT:my_extension/Classes/Controller/ConferenceListController.php**

```php
<?php

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use MyVendor\MyExtension\Service\SharedStorageResolver;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Context\LanguageAspectFactory;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceListController extends ActionController
{
  public function __construct(
    protected readonly ConferenceRepository $conferenceRepository,
    protected readonly SharedStorageResolver $sharedStorageResolver,
  ) {}

  public function listAction(): ResponseInterface
  {
    $storageSite = $this->sharedStorageResolver->resolveStorageSite(
      $this->settings['storageSiteIdentifier'],
    );

    // The language the visitor is browsing, in the site being rendered.
    $currentLanguage = $this->request->getAttribute('language');

    $storageLanguage = $this->sharedStorageResolver->resolveStorageLanguage(
      $storageSite,
      $currentLanguage,
    );

    // storage site does not offer the visitor's locale
    // fall back to storage default language
    $storageLanguage ??= $storageSite->getDefaultLanguage();

    $conferences = $this->conferenceRepository->findAllInStorage(
      $this->settings['storagePageId'],
      LanguageAspectFactory::createFromSiteLanguage($storageLanguage),
    );

    $this->view->assign('conferences', $conferences);
    return $this->htmlResponse();
  }
}

```

## When the storage site does not offer the language {#extbase-cross-site-locales-no-match}

A locale match can fail. The visitor browses a language the storage site simply
does not have, and no amount of resolving will conjure those records.

There is no correct answer the framework can pick for you, so the extension has
to choose one deliberately:

-   **Fall back to the storage site's default language.** Shown above, and the
    usual choice for a shared catalogue: the visitor sees the records in the
    storage site's main language rather than an empty list.
-   **Show nothing.** Appropriate where displaying untranslated content would
    be wrong — legal texts, or anything the site is obliged to present in one
    language only.

What does *not* work is falling back to the language of the site being
rendered. Its UID means nothing in the storage site, which is the problem this
whole page exists to solve.

## When the two sites disagree about fallbacks {#extbase-cross-site-locales-fallbacks}

`fallbackType` is configured per site language, so the storage site and the
rendering site can disagree about what to do with an untranslated record. The
same shared record can be visible on one site and hidden on another, purely
because of that setting.

Which one applies is decided by where the aspect came from:

| The aspect is built from | Translation behavior follows |
| --- | --- |
| The rendered site's language, the default when you do nothing | The rendered site. Records are shown or hidden according to the site the visitor is on, using a language UID that does not belong to the storage site. |
| The storage site's matched language, as shown above | The storage site. Every site reading the folder treats its records the same way. |
| A `\TYPO3\CMS\Core\Context\LanguageAspect` you construct yourself | Whatever you set, independent of both sites. |

Building the aspect from the matched storage language is the recommended
default, and it is what the example does. It makes the shared records behave
consistently wherever they are shown, which is usually the point of sharing
them.

Where the reading site should keep control — a country site that must hide
untranslated content even though the storage site shows it — construct the
aspect explicitly instead: take the language UID from the matched storage
language, and the overlay type and fallback chain manually constructed to suit
your needs. See [Setting a language aspect on a query](https://docs.typo3.org/permalink/t3coreapi:extbase-localisation-query-settings-aspect@main) for building an aspect by hand.

### What this means for relations {#extbase-cross-site-locales-relations}

Relations do not follow the aspect you set. Extbase configures relation queries
itself and passes down **the language of the parent record**, not the language
of the request.

For cross-site reading this is the behavior you want, and it falls out for
free: a conference resolved to the storage site's Italian record fetches its
categories in that same language, without the extension arranging anything.

The consequence to be aware of is that a mismatch propagates. Resolve the
parent record to the wrong language and its relations follow it there, which is
why the locale match belongs at the point where the parent query is built
rather than being corrected afterwards.

> [!NOTE]
> **See also**
>
> [Localization in Extbase](https://docs.typo3.org/permalink/t3coreapi:extbase-localisation@main) —
> overlay types, the `fallbackType` settings and what each one returns.
