diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 4de9e583dca5676f5bda4cf2b7af9fe24ea41abb..b9779195031342161f155990574b61e2c69dd731 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -18,6 +18,7 @@ $config->setRules([
     'strict_param' => true,
     'declare_strict_types' => true,
     'method_argument_space' => ['on_multiline' => 'ignore'],
+    'phpdoc_to_comment' => false,
 ])
 ->setRiskyAllowed(true)
 ->setFinder($finder);
diff --git a/composer.json b/composer.json
index 42a924cb6a3061d8f5c3643211eb07bacf3a8395..f54c7e207eb445ac4c688c257c5e767958605d9c 100644
--- a/composer.json
+++ b/composer.json
@@ -29,7 +29,8 @@
         "symfony/twig-bundle": "^5.4",
         "symfony/uid": "^5.4",
         "symfony/validator": "^5.4",
-        "symfony/yaml": "^5.4"
+        "symfony/yaml": "^5.4",
+        "ext-intl": "*"
     },
     "require-dev": {
         "brainmaestro/composer-git-hooks": "^2.8.5",
diff --git a/composer.lock b/composer.lock
index fd00a981838c0f5ed372e482ddb1aaa383e9130c..b63c9186ae289b79e8c8351bf69061f418d2f14f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "9c42ac8c7816cdf96cd4c2f000cabaf3",
+    "content-hash": "6ddd4889d724c8beabfe6b3b58bf35d7",
     "packages": [
         {
             "name": "api-platform/core",
@@ -9843,7 +9843,8 @@
     "platform": {
         "php": ">=7.3",
         "ext-fileinfo": "*",
-        "ext-json": "*"
+        "ext-json": "*",
+        "ext-intl": "*"
     },
     "platform-dev": [],
     "platform-overrides": {
diff --git a/src/Locale/Locale.php b/src/Locale/Locale.php
new file mode 100644
index 0000000000000000000000000000000000000000..c06bd95a66da260ba44978a8ce038167ab1ea87b
--- /dev/null
+++ b/src/Locale/Locale.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Locale;
+
+use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * A service which can be injected, which provides the current active language and allows setting the active
+ * language based on a query parameters.
+ *
+ * This assumes that Symfony is configured to apply the 'Accept-Language' header by default to all requests.
+ */
+class Locale
+{
+    /** @var RequestStack */
+    private $requestStack;
+
+    /**
+     * @var ParameterBagInterface
+     */
+    private $parameters;
+
+    public function __construct(RequestStack $requestStack, ParameterBagInterface $parameters)
+    {
+        $this->requestStack = $requestStack;
+        $this->parameters = $parameters;
+    }
+
+    /**
+     * Returns the primary language (in ISO 639‑1 format) for the current context.
+     * In case there is a request then the request language, otherwise the default language.
+     */
+    public function getCurrentPrimaryLanguage(): string
+    {
+        $locale = $this->getCurrentLocale();
+        $lang = \Locale::getPrimaryLanguage($locale);
+        /** @psalm-suppress RedundantCondition */
+        assert($lang !== null);
+
+        return $lang;
+    }
+
+    /**
+     * Sets the locale for the active request via a query parameter.
+     * The query parameter format is the same as the 'Accept-Language' HTTP header format.
+     * In case the query parameter isn't part of the request then nothing changes.
+     */
+    public function setCurrentRequestLocaleFromQuery(string $queryParam = 'lang'): void
+    {
+        $request = $this->requestStack->getCurrentRequest();
+        if ($request === null) {
+            throw new \RuntimeException('No active request');
+        }
+        self::setRequestLocaleFromQuery($request, $queryParam);
+    }
+
+    /**
+     * Returns the current locale, either from the active request, or the default one.
+     */
+    private function getCurrentLocale(): string
+    {
+        $request = $this->requestStack->getCurrentRequest();
+        if ($request !== null) {
+            $locale = $request->getLocale();
+        } else {
+            $locale = $this->parameters->get('kernel.default_locale');
+            assert(is_string($locale));
+        }
+
+        return $locale;
+    }
+
+    private static function setRequestLocaleFromQuery(Request $request, string $queryParam): void
+    {
+        if ($request->query->has($queryParam)) {
+            $lang = $request->query->get($queryParam);
+            assert(is_string($lang));
+            $locale = \Locale::acceptFromHttp($lang);
+            if ($locale === false) {
+                throw new \RuntimeException('Failed to parse Accept-Language');
+            }
+            $request->setLocale($locale);
+        }
+    }
+}
diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml
index fef566f3e835fd5ff3bccce6ffb91d70a59ff8af..91df5c01c6fa1f70c8b75bea3972a639525ebb8c 100644
--- a/src/Resources/config/services.yaml
+++ b/src/Resources/config/services.yaml
@@ -100,3 +100,7 @@ services:
   Dbp\Relay\CoreBundle\Authorization\DebugCommand:
     autowire: true
     autoconfigure: true
+
+  Dbp\Relay\CoreBundle\Locale\Locale:
+    autowire: true
+    autoconfigure: true
diff --git a/tests/LocaleTest.php b/tests/LocaleTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e9f924c0fc5f302762f989efe7ad7547a8ba1016
--- /dev/null
+++ b/tests/LocaleTest.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Tests;
+
+use Dbp\Relay\CoreBundle\Locale\Locale;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+class LocaleTest extends TestCase
+{
+    public function testWithRequest()
+    {
+        $stack = new RequestStack();
+        $request = new Request(['lang' => 'de']);
+
+        $request->setLocale(\Locale::acceptFromHttp('en'));
+        $stack->push($request);
+        $params = new ParameterBag([]);
+        $service = new Locale($stack, $params);
+
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('en', $lang);
+
+        $service->setCurrentRequestLocaleFromQuery('lang');
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('de', $lang);
+    }
+
+    public function testWithoutRequest()
+    {
+        $stack = new RequestStack();
+        $params = new ParameterBag(['kernel.default_locale' => \Locale::acceptFromHttp('de')]);
+        $service = new Locale($stack, $params);
+
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('de', $lang);
+    }
+}