Skip to content

Commit 0590e45

Browse files
committed
Propagate current locale to custom 404/403/500 error pages
When no route matches (404/403), Symfony's LocaleListener never runs, so custom error pages and their records rendered in the default locale and ignored the locale in the URL (e.g. /nl/...). - ErrorController recovers the locale from the URL path and applies it to the request, the Twig sub-request, and the translator; the record locale is now passed explicitly to DetailController::record(). - Add redirectToDefaultLocaleOrFallback(): when there's no route to redirect to (error page / forwarded request), reset to the default locale and render instead of erroring. Guards the missing _route case that previously threw a TypeError (a 404-within-a-404). - ListingController uses the fallback so a forwarded listing renders in the default locale instead of erroring. Adds ErrorControllerTest and ListingControllerTest covering localized and default-locale rendering, translator locale recovery, the 403 path, the non-localized ContentType regression, the listing redirect, and the forwarded-listing fallback.
1 parent f3f3073 commit 0590e45

6 files changed

Lines changed: 382 additions & 20 deletions

File tree

phpstan-baseline.php

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,12 +1435,6 @@
14351435
'count' => 1,
14361436
'path' => __DIR__ . '/src/Controller/Frontend/ListingController.php',
14371437
];
1438-
$ignoreErrors[] = [
1439-
'message' => '#^Method Bolt\\\\Controller\\\\Frontend\\\\ListingController\\:\\:listing\\(\\) should return Symfony\\\\Component\\\\HttpFoundation\\\\Response but returns Symfony\\\\Component\\\\HttpFoundation\\\\Response\\|null\\.$#',
1440-
'identifier' => 'return.type',
1441-
'count' => 1,
1442-
'path' => __DIR__ . '/src/Controller/Frontend/ListingController.php',
1443-
];
14441438
$ignoreErrors[] = [
14451439
'message' => '#^Method Bolt\\\\Controller\\\\Frontend\\\\ListingController\\:\\:parseQueryParams\\(\\) return type has no value type specified in iterable type array\\.$#',
14461440
'identifier' => 'missingType.iterableValue',
@@ -1525,12 +1519,6 @@
15251519
'count' => 1,
15261520
'path' => __DIR__ . '/src/Controller/TwigAwareController.php',
15271521
];
1528-
$ignoreErrors[] = [
1529-
'message' => '#^Method Bolt\\\\Controller\\\\TwigAwareController\\:\\:renderSingle\\(\\) should return Symfony\\\\Component\\\\HttpFoundation\\\\Response but returns Symfony\\\\Component\\\\HttpFoundation\\\\Response\\|null\\.$#',
1530-
'identifier' => 'return.type',
1531-
'count' => 1,
1532-
'path' => __DIR__ . '/src/Controller/TwigAwareController.php',
1533-
];
15341522
$ignoreErrors[] = [
15351523
'message' => '#^Method Bolt\\\\Controller\\\\TwigAwareController\\:\\:renderTemplate\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#',
15361524
'identifier' => 'missingType.iterableValue',

src/Controller/ErrorController.php

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2222
use Symfony\Component\HttpKernel\HttpKernelInterface;
2323
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
24+
use Symfony\Contracts\Translation\LocaleAwareInterface;
25+
use Symfony\Contracts\Translation\TranslatorInterface;
2426
use Throwable;
2527
use Twig\Environment;
2628
use Twig\Error\LoaderError;
@@ -37,10 +39,17 @@ public function __construct(
3739
private readonly UrlGeneratorInterface $urlGenerator,
3840
private readonly Security $security,
3941
private readonly RequestStack $requestStack,
42+
private readonly TranslatorInterface $translator,
43+
string $locales,
4044
) {
4145
parent::__construct($httpKernel, $this->templateController, $errorRenderer);
46+
47+
$this->localeCodes = explode('|', $locales);
4248
}
4349

50+
/** @var list<string> */
51+
private readonly array $localeCodes;
52+
4453
/**
4554
* Show an exception. Mainly used for custom 404 pages, otherwise falls back
4655
* to Symfony's error handling
@@ -61,6 +70,11 @@ public function showAction(Environment $twig, Throwable $exception): Response
6170

6271
// We need the parent request here, but fall back to current if not found
6372
if ($request = $this->requestStack->getParentRequest() ?? $this->requestStack->getCurrentRequest()) {
73+
// On a 404/403, no route matched, so Symfony's LocaleListener never set
74+
// the locale from the URL. Recover it from the path so localized error
75+
// pages (and their records) render in the right language.
76+
$this->setLocaleFromPath($request);
77+
6478
if ($code === Response::HTTP_SERVICE_UNAVAILABLE || $this->isMaintenanceEnabled($code)) {
6579
$twig->addGlobal('exception', $exception);
6680

@@ -156,6 +170,41 @@ private function isMaintenanceEnabled(int $code): bool
156170
return filter_var($this->config->get('general/maintenance_mode', false), FILTER_VALIDATE_BOOLEAN);
157171
}
158172

173+
/**
174+
* Sets the locale based on the first segment of the path, if it matches one
175+
* of the configured locales (e.g. `/de/...` => `de`).
176+
*
177+
* The locale is applied to both the given request (used when rendering a
178+
* record) and the current request (which Twig's `app.request` resolves to,
179+
* and is a sub-request when an error page is being rendered). It's also set on
180+
* the translator, so `{% trans %}` strings in the error template are localized
181+
* too - normally Symfony's `LocaleListener`/`LocaleAwareListener` does this,
182+
* but neither runs when no route matched.
183+
*/
184+
private function setLocaleFromPath(Request $request): void
185+
{
186+
// Cast: on PHP 8.4 `mb_trim()` is analysed as `string|false`, but `getPathInfo()`
187+
// always yields a string, so the result is effectively always a string here.
188+
$segment = explode('/', (string) mb_trim($request->getPathInfo(), '/'))[0];
189+
190+
if ($segment === '' || ! in_array($segment, $this->localeCodes, true)) {
191+
return;
192+
}
193+
194+
$request->setLocale($segment);
195+
196+
$currentRequest = $this->requestStack->getCurrentRequest();
197+
if ($currentRequest instanceof Request && $currentRequest !== $request) {
198+
$currentRequest->setLocale($segment);
199+
}
200+
201+
// The concrete translator is locale-aware; the contracts interface we depend
202+
// on isn't, so guard the call to keep the dependency narrow.
203+
if ($this->translator instanceof LocaleAwareInterface) {
204+
$this->translator->setLocale($segment);
205+
}
206+
}
207+
159208
private function attemptToRender(Request $request, string $item): ?Response
160209
{
161210
// First, see if it's a contenttype/slug pair:
@@ -165,7 +214,9 @@ private function attemptToRender(Request $request, string $item): ?Response
165214
// We wrap it in a try/catch, because we wouldn't want to
166215
// trigger a 404 within a 404 now, would we?
167216
try {
168-
return $this->detailController->record($request, $slug, $contentType, false, null);
217+
// Pass the request's locale explicitly, so `DetailController` keeps
218+
// it instead of falling back to the default locale.
219+
return $this->detailController->record($request, $slug, $contentType, false, $request->getLocale());
169220
} catch (NotFoundHttpException) {
170221
// Just continue to the next one.
171222
}

src/Controller/Frontend/ListingController.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ public function listing(Request $request, ContentRepository $contentRepository,
4444
throw new NotFoundHttpException('Content is not viewable');
4545
}
4646

47-
// If the locale is the wrong locale
48-
if (! $this->validLocaleForContentType($request, $contentType)) {
49-
return $this->redirectToDefaultLocale($request);
47+
// If the locale is the wrong locale, redirect to the default locale, or -
48+
// when that's not possible (e.g. a forwarded request without a matched
49+
// route) - render the listing in the default locale.
50+
if (! $this->validLocaleForContentType($request, $contentType)
51+
&& ($redirect = $this->redirectToDefaultLocaleOrFallback($request)) instanceof Response) {
52+
return $redirect;
5053
}
5154

5255
$page = (int) $this->getFromRequest($request, 'page', '1');

src/Controller/TwigAwareController.php

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,11 @@ public function renderSingle(Request $request, ?Content $record, bool $requirePu
113113
throw new NotFoundHttpException('Content is not viewable');
114114
}
115115

116-
// If the locale is the wrong locale
117-
if (! $this->validLocaleForContentType($request, $recordDefinition)) {
118-
return $this->redirectToDefaultLocale($request);
116+
// If the locale is the wrong locale, redirect to the default locale, or -
117+
// when that's not possible - render the record in the default locale.
118+
if (! $this->validLocaleForContentType($request, $recordDefinition)
119+
&& ($redirect = $this->redirectToDefaultLocaleOrFallback($request)) instanceof Response) {
120+
return $redirect;
119121
}
120122

121123
$singularSlug = $record->getContentTypeSingularSlug();
@@ -145,8 +147,41 @@ protected function validLocaleForContentType(Request $request, ContentType $cont
145147
return $request->getLocale() === $this->defaultLocale;
146148
}
147149

150+
/**
151+
* Either redirect to the same route in the default locale, or - when there's
152+
* no route to redirect to (e.g. a forwarded request, or an error page where
153+
* routing never matched) - reset the request to the default locale and return
154+
* `null`, so the caller can render in the default locale instead.
155+
*
156+
* Note: this resets the locale on the _given_ request only. When rendering an
157+
* error page, Twig's `app.request` is a sub-request whose locale was set
158+
* separately (see ErrorController::setLocaleFromPath()), so the `<html lang>`
159+
* may still reflect the URL locale while the - non-localizable - record content
160+
* is rendered in the default locale. That's harmless: such content is identical
161+
* across locales.
162+
*/
163+
protected function redirectToDefaultLocaleOrFallback(Request $request): ?Response
164+
{
165+
$redirect = $this->redirectToDefaultLocale($request);
166+
167+
if ($redirect instanceof Response) {
168+
return $redirect;
169+
}
170+
171+
$request->setLocale($this->defaultLocale);
172+
173+
return null;
174+
}
175+
148176
protected function redirectToDefaultLocale(Request $request): ?Response
149177
{
178+
// No route was matched (e.g. on an error page): there's nothing to
179+
// redirect to, so let the caller decide how to handle this.
180+
$route = $request->attributes->get('_route');
181+
if (! $route) {
182+
return null;
183+
}
184+
150185
$request->getSession()->set('_locale', $this->defaultLocale);
151186

152187
$params = $request->attributes->get('_route_params');
@@ -155,7 +190,7 @@ protected function redirectToDefaultLocale(Request $request): ?Response
155190
$params['_locale'] = $this->defaultLocale;
156191
}
157192

158-
return $this->redirectToRoute($request->get('_route'), $params);
193+
return $this->redirectToRoute($route, $params);
159194
}
160195

161196
private function setTwigLoader(): void

0 commit comments

Comments
 (0)