Skip to content

Propagate current locale to custom 404/403/500 error pages#3726

Open
Vondry wants to merge 2 commits into
bolt:6.2from
Vondry:feature/localized-error-pages
Open

Propagate current locale to custom 404/403/500 error pages#3726
Vondry wants to merge 2 commits into
bolt:6.2from
Vondry:feature/localized-error-pages

Conversation

@Vondry
Copy link
Copy Markdown
Contributor

@Vondry Vondry commented May 25, 2026

Propagate the current locale to custom 404 / 403 / 500 error pages

Problem

When a request hits a custom error page configured in config/bolt/config.yaml
(notfound, forbidden, internal_server_error, maintenance), the page was
always rendered in the default locale, even when the URL clearly carried a
locale (e.g. /nl/this-page-does-not-exist).

Root cause: on a 404/403, no route matches, so Symfony's LocaleListener never
runs and the request locale is never set from the URL. As a result:

  • Localized record fields (e.g. a heading) rendered in the default language.
  • <html lang="…"> did not reflect the requested locale.
  • {% trans %} strings in the error template rendered in the default language
    (the translator's locale was never set either).

There was also a latent bug: forcing the "wrong locale" path through
redirectToDefaultLocale() on a request without a matched route called
redirectToRoute(null, …), throwing a TypeError — a 404-within-a-404.

Solution

  1. Recover the locale from the URL path on error pages.
    ErrorController now receives the configured locales (%app_locales%) and,
    in showAction(), recovers the locale from the first path segment
    (/de/…de) before rendering. It's applied to:

    • the request used to render the record,
    • the current (sub-)request that Twig's app.request resolves to, and
    • the translator (so {% trans %} strings localize too).

    The translator call is guarded by instanceof LocaleAwareInterface: the
    contracts TranslatorInterface we depend on exposes only trans()/
    getLocale(), while setLocale() lives on LocaleAwareInterface (the
    concrete translator implements both). This keeps the declared dependency
    narrow.

  2. Pass the locale explicitly into record rendering.
    attemptToRender() now forwards $request->getLocale() to
    DetailController::record(), so it keeps the recovered locale instead of
    falling back to the default.

  3. Make the "wrong locale" handling degrade gracefully.
    Introduced redirectToDefaultLocaleOrFallback(): when there is a matched
    route it still redirects to the default-locale URL as before; when there
    isn't one (an error page, or a forwarded request) it resets the request to
    the default locale and returns null, so the caller renders in the default
    locale instead of erroring. redirectToDefaultLocale() now guards the missing
    _route case (fixing the TypeError) and reads _route from request
    attributes rather than $request->get().

Changes

File Change
src/Controller/ErrorController.php Inject %app_locales% and the translator; recover the locale from the path (setLocaleFromPath()) onto the request, sub-request and
translator; pass the locale to DetailController::record().
src/Controller/TwigAwareController.php Add redirectToDefaultLocaleOrFallback(); guard the no-_route case in redirectToDefaultLocale() and read _route from
attributes.
src/Controller/Frontend/ListingController.php Use the new fallback so a forwarded listing renders in the default locale instead of erroring.
tests/php/Controller/Frontend/ErrorControllerTest.php New tests (see below).
tests/php/Controller/Frontend/ListingControllerTest.php New tests (see below).

Tests

ErrorControllerTest:

  • testNotFoundRecordRendersLocalizedFieldInRequestedLocale/nl/… 404 renders
    <html lang="nl"> and the Dutch heading.
  • testNotFoundRecordRendersLocalizedFieldInDefaultLocale — no-locale 404 renders
    in en.
  • testErrorPageRecoversTranslatorLocaleFromPath — the shared translator's locale
    is set to nl for a /nl/… error (default is en).
  • testForbiddenRecordRendersLocalizedFieldInRequestedLocale — the 403 branch
    (showForbidden) localizes the configured general/forbidden record.
  • testNotFoundPageWithNonLocalizedContentTypeDoesNotError — regression for the
    TypeError when a non-localized ContentType is reached via a localized URL.

ListingControllerTest:

  • testListingRedirectsToDefaultLocaleWhenLocaleNotSupported — matched route with
    an unsupported locale ⇒ 302 to the default locale.
  • testListingFallsBackToDefaultLocaleWhenForwardedWithoutRoute — forwarded
    request (no _route) ⇒ renders in the default locale instead of erroring.

Results: new tests green (ErrorControllerTest 5/5, ListingControllerTest
2/2). Full suite 195/196 — the single failure (ConfigTest::testCanParse, a
Console\Application::setDispatcher(null) TypeError) is a pre-existing
test-ordering issue
unrelated to this change: it touches none of these files and
passes in isolation. PHPStan and ECS are clean on all changed files.

Notes / limitations

  • Test approach for 403: a frontend 403 isn't reachable from a routed request
    in the test app (all access_control rules are backend, and the backend 403
    path redirects to the dashboard rather than rendering the custom page), so that
    test drives the configured error_controller directly — the same entry point
    Symfony's error handling uses. showAction() returns the rendered page as a
    plain 200; promoting the status to the exception's code (403/404/…) is the
    kernel's job (HttpKernel::handleThrowable()), which the direct call bypasses —
    so that test asserts the locale handling that is this controller's
    responsibility, not the status code.
  • Non-localized ContentType via a localized error URL: <html lang> may show
    the URL locale while the record content uses the default locale. This is
    harmless (such content is identical across locales) and is documented inline.
  • Constructor change: ErrorController::__construct gains a required
    string $locales argument and a TranslatorInterface; both are autowired/bound
    via DI (config/services.yaml). Relevant only if the controller is extended or
    instantiated manually.

@Vondry Vondry force-pushed the feature/localized-error-pages branch from 206c1ca to 1222eef Compare May 25, 2026 16:49
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.
@Vondry Vondry force-pushed the feature/localized-error-pages branch from 1222eef to 0590e45 Compare May 25, 2026 16:55
On PHP 8.4, mb_trim()/mb_rtrim() are analysed as string|false (the
mbstring polyfill's implementation has no return type, and the native
8.4 stubs are nullable-on-failure). Combined with json_encode()'s
existing string|false return, this surfaced level-8 errors in files
unrelated to any one feature.

Add explicit (string) casts at the call sites in Canonical, the
translation trait, the frontend menu builder, the content repository
search, and the excerpt helper. These are no-ops on PHP 8.2/8.3 and
satisfy the analyser on 8.4. Drop the now-obsolete ContentRepository
mb_trim baseline entry, which the json_encode cast resolves.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant