Skip to content

Add support for Symfony localized routes for multisite#1819

Open
haivala wants to merge 14 commits intosonata-project:4.xfrom
haivala:4.x
Open

Add support for Symfony localized routes for multisite#1819
haivala wants to merge 14 commits intosonata-project:4.xfrom
haivala:4.x

Conversation

@haivala
Copy link
Copy Markdown
Contributor

@haivala haivala commented Sep 30, 2025

Summary

This PR adds native support for Symfony's localized routing with per-locale URL prefixes, enabling multisite applications with strict locale isolation and structural 404s for wrong-locale URLs.

Key benefits:

  • ✅ Native Symfony localized routes (e.g., app_home.fi, app_home.en)
  • ✅ Structural 404s for wrong-locale URLs (no redirects or fallbacks)
  • ✅ Consistent URL prefix handling for both CMS pages and Symfony routes
  • ✅ Automatic locale-aware URL generation
  • ✅ Prevents hybrid page duplication across locales

Example setup:

# Routes with locale suffixes
app_home:
    path: 
        fi: /
        en: /en

# Sites with locale prefixes
$finnishSite->setLocale('fi')->setRelativePath('');    # Finnish at root
$englishSite->setLocale('en')->setRelativePath('/en'); # English with /en prefix

Implementation

Core Components

  1. SiteAwareRouter (new)

    • Decorates router.default to partition routes by locale suffix
    • Only matches routes for the current site's locale (structural 404s)
    • Auto-resolves base route names to locale variants (e.g., app_homeapp_home.fi)
    • Supports explicit locale forcing via _locale parameter
  2. RoutePartitioner (new)

    • One-time indexing of routes by .{locale} suffix
    • Filters by enabled site locales to prevent false positives (e.g., admin.dashboard treated as neutral, not locale dashboard)
    • Zero-regex hot path using strrpos() and substr()
  3. CmsPageRouter (modified)

    • Unified prefix handling via SiteRequestContext::getBaseUrl()
    • Maps bare prefix path (e.g., /en) to root page (/) for English site
    • Strips site prefix during matching for clean page URLs
  4. RoutePageGenerator (modified)

    • Filters hybrid pages by locale to prevent duplication
    • Skips creating *.en hybrid pages on Finnish sites

Request Flow

Matching (incoming requests):

  1. SiteAwareRouter builds locale-specific matcher (lazy)
  2. Only routes for current site's locale + neutral routes are available
  3. Wrong-locale URLs return 404 (structural, not fallback)
  4. Falls through to CmsPageRouter if no route match

Generation (URL creation):

// Auto-resolves to current site's locale variant
$url = $this->generateUrl('app_products');
// Finnish site: /tuotteet (app_products.fi)
// English site: /en/products (app_products.en)

// Force specific locale
$url = $this->generateUrl('app_products', ['_locale' => 'en']);
// Always returns: /en/products

Breaking Changes

⚠️ Routes with dots are now treated as neutral unless they match {base}.{locale} format:

  • admin.dashboard → neutral route (not locale dashboard)
  • api.v2 → neutral route (not locale v2)
  • To make these localized, rename: admin_dashboard.en, api_v2.fi

Migration Guide

If NOT using localized routing:

  • ✅ No changes needed - existing routes work as before

If WANT to use localized routing:

  1. Create locale-specific route variants:
# Before
app_products:
    path: /products

# After
app_products:
    path: 
        fi: '/tuotteet'
        en: '/en/products'
  1. Configure sites with relativePath:
$finnishSite->setRelativePath('');    // Root
$englishSite->setRelativePath('/en'); // /en prefix
  1. Regenerate hybrid pages:
bin/console sonata:page:update-core-routes --site=all
bin/console sonata:page:create-snapshots --site=all

See docs/reference/localized_routing.rst for full documentation.

I am targeting this branch because this adds a new feature with minimal breaking changes (only affects routes with dots in names).

Closes #1744

Changelog

### Added
- Native support for Symfony localized routes with `.{locale}` suffix convention
- `SiteAwareRouter` for automatic route partitioning by locale
- `RoutePartitioner` for indexing routes by locale suffix
- Locale filtering based on enabled sites to prevent false positives
- Structural 404s for wrong-locale URLs (no redirects or fallbacks)
- Automatic locale-aware URL generation with base route name resolution
- Comprehensive documentation in `docs/reference/localized_routing.rst`
- Unit tests for `SiteAwareRouter` and `RoutePartitioner` (24 tests, 100% coverage)

### Changed
- `RoutePageGenerator` now filters hybrid pages by locale to prevent duplication
- `CmsPageRouter` uses unified prefix handling via `SiteRequestContext::getBaseUrl()`
- Routes like `admin.dashboard` now treated as neutral (not locale `dashboard`)

### Fixed
- Double locale prefixes in URLs
- Hybrid page duplication across different locale sites
- Inconsistent prefix handling between CMS pages and Symfony routes

To do

  • Update the tests
  • Update the documentation
  • Add an upgrade note
  • Fix CI/CD errors (PHP-CS-Fixer, Psalm, Rector)
  • Figure out could this be the future of this bundle;

haivala and others added 2 commits September 30, 2025 15:06
  Introduces SiteAwareRouter for automatic route partitioning by locale,
  enabling multisite applications with locale-specific URL structures.

  Key features:
  - Automatic route partitioning by .{locale} suffix
  - Structural 404s for wrong-locale URLs
  - Per-request caching of allowed locales
  - 24 unit tests with 100% coverage
  - Comprehensive documentation

  Breaking changes:
  - Routes with dots treated as neutral unless matching {base}.{locale} format
  - SiteRequestContext::getBaseUrl() includes site relativePath

  Fixes sonata-project#1819
@haivala haivala changed the title DRAFT: Add support for Symfony localized routes Add support for Symfony localized routes Oct 1, 2025
@haivala haivala marked this pull request as draft October 1, 2025 07:56
@haivala haivala marked this pull request as ready for review October 1, 2025 08:26
@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Oct 1, 2025

Actually, should I target 4.x as this is mainly adding functionality?

@eerison
Copy link
Copy Markdown
Contributor

eerison commented Oct 22, 2025

Hey @haivala

would it be possible siplit those featues in small PRs? it will be easier to review.
and other question, all features contains BC? if no we could merge just BC into 5.x and the rest into 4.x.

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Oct 23, 2025

I have been using this now for weeks in production and it works really well. I should mention that I'm using multisite: host_with_path_by_locale.

This is basically bug fix for the locale routing as it forces users to define locale routes for everything (as it is best practice). Before this all Symfony routes worked with all Sites without locale/prefix definition. For example: if you defined login Symfony route it would work in all defined PageBundle Site locales regardless the locale you defined for the login route. In fact if you defined locale for the Symfony route and ignored it in sonata_page.ignore_routes it would beak because Symfony added locale prefix for it and then the Site would add another prefix for it too. You would end up with /en/en/login and that does not exist. Twig path function would generate that url and you could not force locale with it. With this all Symfony locale routes need to be defined and you can choose if the login works only in one locale and twig functions works as expected (with forced _locale parameter or by defining route and adding the locale to it path('login.en')). I have been adding hacky implementations to go around this limitation for 5 years.

I do not have time to make smaller PRs from this, at least for now.

@eerison
Copy link
Copy Markdown
Contributor

eerison commented Oct 23, 2025

I'm not sure if your changes are related with this new feature added on symfony 6.1

But it looks promising for this bundle: https://symfony.com/blog/new-in-symfony-6-1-locale-switcher

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Oct 24, 2025

One more example where PageBundle breaks stuff. You install LiipImageBundle and try to use it. The Site adds locale prefix to all media requests and because of that they do not work. I solved this by symbolic linking the cache images for the prefix folder.

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Oct 31, 2025

I think this should this be merged to 4.x? Just remove the BCHelper?

@eerison
Copy link
Copy Markdown
Contributor

eerison commented Oct 31, 2025

This pr need to be split into small ones, it is too big to be merged :/

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 3, 2025

This pr need to be split into small ones, it is too big to be merged :/

How?

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 5, 2025

how about just rebasing this to 4.x as most of the changes are there already? like the BCHelper

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 6, 2025

I have now time to do this. What should I do?

@eerison
Copy link
Copy Markdown
Contributor

eerison commented Nov 6, 2025

Hi @haivala

Well I guess you are the best one to figure out which changes could be merged in others pr.

I do not know if it possible.

But would be possible to create prs for thoses fix.

  • Double locale prefixes in URLs
  • Hybrid page duplication across different locale sites
  • Inconsistent prefix handling between CMS pages and Symfony routes

For example this double locale prefix, isn't it possible provide a fix just for this?

Maybe start writing a test, it will be easier to visualise the root issue. WDYT?

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 7, 2025

no. they are all tied together.
The double prefix has always(!) been a bug and that is the bug I was trying to fix. Those other two are just side effects of fixing the one.

I think it would be best to merge this to 4.x. This only effects on multisite with locale projects and fixes them. Main fix is the route partitioner. Looks like everything else on this MR has been already merged to 4.x. Only problem I see is that if someone uses multisite setup: after this they need to actually configure the i8n routing in Symfony. Now they have to ignore i8n routing setup for Symfony which in itself is a bug.

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 7, 2025

Would be nice to have answer soon because I have now time to do this.

@eerison
Copy link
Copy Markdown
Contributor

eerison commented Nov 7, 2025

What I can say is, try to figure out what can be merge separated in small PR, if it isn't possible. then I do not have any advise.

and PRs with more then 10 files is a no go to merge.

I can't guarantee that I can support with this issue/feature. But try to write some functional/unit test that cover what you want to archive with the current code. then it will be easier to visualize what you want solve.

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 7, 2025

I can see that you did not even check what there is as you did not even notice that there is tests. This is bug fix and at the same time as it is a feature. Little support would be nice

@haivala haivala changed the base branch from 5.x to 4.x November 7, 2025 17:00
@haivala haivala marked this pull request as draft November 7, 2025 17:01
@haivala haivala marked this pull request as ready for review November 7, 2025 17:21
@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 7, 2025

There is code, documentation and tests. Sorry that it is more than 10 files

@eerison
Copy link
Copy Markdown
Contributor

eerison commented Nov 8, 2025

@haivala

Could you write a functional test, reproducing the issue?

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 11, 2025

I do not have time. Here is controller I used to test this

<?php

declare(strict_types=1);

namespace App\Controller;

use Sonata\PageBundle\Site\SiteSelectorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
 * Controller to exercise the Symfony localized route generation across locales
 * with automatic site-locale selection and explicit _locale forcing.
 *
 * NOTE:
 *  Add "path_test" to sonata_page.ignore_routes in config/packages/sonata_page.yaml
 *  so Sonata PageBundle does not attempt to create a hybrid CMS Page for it:
 *
 *    sonata_page:
 *      ignore_routes:
 *        - path_test
 *        # (other existing ignored routes...)
 *
 *  This route relies on Symfony resource-level locale prefixing (fi:"", en:"/en")
 *  and defines the English localized path WITHOUT embedding /en so that:
 *   - Generation via base route name + _locale never double-prefixes.
 *   - Cross-locale generation from the Finnish site yields /en/path-test.
 *
 *  Finnish variant (effective): /path-testi
 *  English variant (effective): /en/path-test
 */
final class PathTestController extends AbstractController
{
    #[Route(
        path: [
            'fi' => '/path-testi',
            // English variant intentionally omits /en because the site relativePath
            // (and the locale-aware router decorator) will supply the prefix when appropriate.
            'en' => '/path-test',
        ],
        name: 'path_test',
        env: 'dev',
    )]
    public function index(
        Request $request,
        UrlGeneratorInterface $router,
        SiteSelectorInterface $siteSelector,
    ): Response {
        $currentSite = $siteSelector->retrieve();
        $currentLocale = $request->attributes->get('_locale') ?? $currentSite?->getLocale() ?? 'n/a';

        // Automatic site-locale generation (no _locale param) and forced cross-locale variants
        $autoRelative = $router->generate('path_test', [], UrlGeneratorInterface::ABSOLUTE_PATH);
        $autoAbsolute = $router->generate('path_test', [], UrlGeneratorInterface::ABSOLUTE_URL);
        $autoNetwork = $router->generate('path_test', [], UrlGeneratorInterface::NETWORK_PATH);

        // Forced variants (explicit locale override)
        // Use ABSOLUTE_PATH so "relative" values always include a leading slash.
        $fiRelative = $router->generate('path_test', ['_locale' => 'fi'], UrlGeneratorInterface::ABSOLUTE_PATH);
        $fiAbsolute = $router->generate('path_test', ['_locale' => 'fi'], UrlGeneratorInterface::ABSOLUTE_URL);
        $fiNetwork = $router->generate('path_test', ['_locale' => 'fi'], UrlGeneratorInterface::NETWORK_PATH);

        $enRelative = $router->generate('path_test', ['_locale' => 'en'], UrlGeneratorInterface::ABSOLUTE_PATH);
        $enAbsolute = $router->generate('path_test', ['_locale' => 'en'], UrlGeneratorInterface::ABSOLUTE_URL);
        $enNetwork = $router->generate('path_test', ['_locale' => 'en'], UrlGeneratorInterface::NETWORK_PATH);

        // Validate automatic route generation matches current locale
        // Note: In multisite setup, English site has relativePath="/en", so the generated URL
        // includes the prefix. Finnish has relativePath=null, so no prefix.
        $expectedAutoPath = 'en' === $currentLocale ? '/en/path-test' : '/path-testi';
        $autoMatchesCurrentLocale = $autoRelative === $expectedAutoPath;

        // Cross-locale generation summary
        $data = [
            'current_locale' => $currentLocale,
            'current_site_relative_path' => $currentSite?->getRelativePath(),
            'client_ip' => [
                'client_ip' => $request->getClientIp(),
                'server_remote_addr' => $request->server->get('REMOTE_ADDR'),
                'x_forwarded_for' => $request->server->get('HTTP_X_FORWARDED_FOR'),
                'x_real_ip' => $request->server->get('HTTP_X_REAL_IP'),
                'cf_connecting_ip' => $request->server->get('HTTP_CF_CONNECTING_IP'),
            ],
            'routes' => [
                'auto' => [
                    'relative' => $autoRelative,
                    'absolute' => $autoAbsolute,
                    'network' => $autoNetwork,
                    'locale' => $currentLocale,
                ],
                'forced' => [
                    'fi' => [
                        'relative' => $fiRelative,
                        'absolute' => $fiAbsolute,
                        'network' => $fiNetwork,
                    ],
                    'en' => [
                        'relative' => $enRelative,
                        'absolute' => $enAbsolute,
                        'network' => $enNetwork,
                    ],
                ],
            ],
            'expectations' => [
                'auto.matches_current_locale' => $autoMatchesCurrentLocale,
                'auto.expected_path' => $expectedAutoPath,
                'auto.actual_path' => $autoRelative,
                'fi.relative_should_be' => '/path-testi',
                // Expect English relative URL (always begins with a leading slash):
                //   - Finnish site forcing en: /en/path-test
                //   - English site: /en/path-test (includes site relativePath)
                'en.relative_should_be' => '/en/path-test',
                'en.matches_expected' => '/en/path-test' === $enRelative,
                'no_double_en_prefix' => !str_contains($enRelative, '/en/en/'),
            ],
        ];

        if ($request->query->getBoolean('json', false) || 'json' === $request->getRequestFormat()) {
            return new JsonResponse($data);
        }

        // Simple inline HTML (kept here to avoid requiring a new template file).
        $html = $this->renderHtml($data);

        return new Response($html);
    }

    private function renderHtml(array $data): string
    {
        $esc = static fn (string $v): string => htmlspecialchars($v, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');

        // Auto-generation rows (no _locale parameter - uses context locale)
        $autoRows = [];
        foreach (['relative', 'absolute', 'network'] as $kind) {
            $url = $data['routes']['auto'][$kind];
            $autoRows[] = \sprintf(
                '<tr><td>%s</td><td><code>%s</code></td></tr>',
                $esc($kind),
                $esc($url)
            );
        }

        $rows = [];
        foreach (['fi', 'en'] as $loc) {
            foreach (['relative', 'absolute', 'network'] as $kind) {
                $url = $data['routes']['forced'][$loc][$kind];
                $rows[] = \sprintf(
                    '<tr><td>%s</td><td>%s</td><td><code>%s</code></td></tr>',
                    $esc($loc),
                    $esc($kind),
                    $esc($url)
                );
            }
        }

        $expectRows = [];
        foreach ($data['expectations'] as $k => $v) {
            $display = \is_bool($v) ? ($v ? '✅ true' : '❌ false') : (string) $v;
            $style = '';
            if (\is_bool($v)) {
                $style = $v ? 'background: #d4edda; color: #155724;' : 'background: #f8d7da; color: #721c24;';
            }
            $expectRows[] = \sprintf(
                '<tr style="%s"><td>%s</td><td><code>%s</code></td></tr>',
                $style,
                $esc($k),
                $esc($display)
            );
        }

        $autoRowsHtml = implode('', $autoRows);
        $rowsHtml = implode('', $rows);
        $expectHtml = implode('', $expectRows);

        $ipRows = [];
        foreach ($data['client_ip'] as $k => $v) {
            $ipRows[] = \sprintf(
                '<tr><td>%s</td><td><code>%s</code></td></tr>',
                $esc($k),
                $esc((string) ($v ?? 'null'))
            );
        }
        $ipHtml = implode('', $ipRows);

        return <<<HTML
<!DOCTYPE html>
<html lang="{$esc($data['current_locale'])}">
<head>
  <meta charset="utf-8">
  <title>Path Test Controller</title>
  <style>
    body { font-family: system-ui, Arial, sans-serif; margin: 2rem; }
    table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; }
    th, td { border: 1px solid #ccc; padding: .5rem .75rem; text-align: left; }
    th { background: #f5f5f5; }
    code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; }
    caption { text-align: left; font-weight: bold; padding: .25rem 0 .5rem; }
    .client-ip { background: #e8f4f8; padding: 1rem; border-radius: 5px; margin-bottom: 2rem; }
  </style>
</head>
<body>
  <h1>Path Test Controller</h1>
  <p>Current locale: <strong>{$esc($data['current_locale'])}</strong></p>
  <p>Current site relativePath: <strong>{$esc((string) ($data['current_site_relative_path'] ?? ''))}</strong></p>

  <div class="client-ip">
    <h2>Client IP Information</h2>
    <table>
      <thead>
        <tr><th>Source</th><th>Value</th></tr>
      </thead>
      <tbody>
        {$ipHtml}
      </tbody>
    </table>
  </div>

  <table style="background: #fff3cd; border: 2px solid #ffc107;">
    <caption style="color: #856404;">🔍 Automatic Route Generation (no _locale parameter)</caption>
    <thead>
      <tr><th>Type</th><th>Generated URL</th></tr>
    </thead>
    <tbody>
      {$autoRowsHtml}
    </tbody>
  </table>
  <p style="background: #d1ecf1; padding: 1rem; border-radius: 5px; border-left: 4px solid #0c5460;">
    <strong>Key Test:</strong> The URLs above are generated WITHOUT setting <code>_locale</code>.
    They should automatically use the current site's locale (<strong>{$esc($data['current_locale'])}</strong>).
    <br>
    Expected:
    <code>{$esc('en' === $data['current_locale'] ? '/en/path-test' : '/path-testi')}</code>
    <br>
    <small>(English includes /en prefix from site relativePath)</small>
  </p>

  <table>
    <caption>Forced Locale URLs (with explicit _locale parameter)</caption>
    <thead>
      <tr><th>Route locale</th><th>Type</th><th>URL</th></tr>
    </thead>
    <tbody>
      {$rowsHtml}
    </tbody>
  </table>

  <table>
    <caption>Expectations / Checks</caption>
    <thead>
      <tr><th>Key</th><th>Value / Status</th></tr>
    </thead>
    <tbody>
      {$expectHtml}
    </tbody>
  </table>

  <p>JSON version: append <code>?json=1</code></p>
</body>
</html>
HTML;
    }
}

@haivala
Copy link
Copy Markdown
Contributor Author

haivala commented Nov 12, 2025

please review this?

@haivala haivala changed the title Add support for Symfony localized routes Add support for Symfony localized routes for multisite Nov 14, 2025
Copy link
Copy Markdown
Member

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it introduces BC break, it will need a configuration to enabled this new behavior IMHO.

cc @dmaicher @eerison I dunno if you can help me on this review...

Comment on lines +239 to +245
// Register the locale routing listener only if the feature is enabled.
// ContainerConfigurator does not allow branching on parameter values at compile time,
// so we always define the service, but we add the event listener tag only when the
// boolean parameter is true via a small runtime guard service (the listener itself
// short-circuits when disabled). To avoid even registering the tag when disabled,
// we rely on a compiler pass (optional) — if not present, the early-return in the
// listener keeps overhead negligible.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot understand why comment was added and 0 real code here.

Comment on lines +75 to +80
// Short-circuit: if this is not a page route (not an alias/slug), delegate directly
// to the inner router to avoid double prefixing or unintended CMS decoration.
// This includes both Symfony localized routes (with .<locale> suffix) and regular routes.
if (\is_string($name) && !$this->isPageAlias($name) && !$this->isPageSlug($name)) {
return $this->router->generate($name, $parameters, $referenceType);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a small change.

If i understand before this, the method was throwing a notFound exception while now it delegates the generation.

Can't this change be in a single PR ?

Comment on lines +140 to +156
// Normalize path for CMS page lookup without altering the original request path
// so Symfony's own localized route matching (with prefixes) remains intact.
$lookupPath = $pathinfo;
$relativePath = $site->getRelativePath();

// Map bare site prefix (e.g. "/en") directly to the root CMS page ("/") for that site.
// This allows the English homepage to resolve at "/en" while the stored page URL remains "/".
if (null !== $relativePath && '' !== $relativePath && '/' !== $relativePath && $lookupPath === rtrim($relativePath, '/')) {
$lookupPath = '/';
}

if (null !== $relativePath && '' !== $relativePath && '/' !== $relativePath && str_starts_with($lookupPath, $relativePath.'/')) {
$lookupPath = substr($lookupPath, \strlen($relativePath));
if ('' === $lookupPath) {
$lookupPath = '/';
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're changing the match behavior which might either be considered as a bugfix or a BC break and the benefit of this change is unclear to me.

Can't it be in a single PR ?

Comment on lines +67 to +77
// Detect a locale suffix in the route name (e.g. ".en", ".fi").
// If present and it does not match the current site's locale, skip creating/updating
// a hybrid page entry for this site. This prevents duplicating localized hybrid pages
// across all sites and ensures wrong-locale routes 404 instead of falling back.
if (1 === preg_match('/\\.([A-Za-z_]+)$/', $name, $lm)) {
$routeLocale = $lm[1];
$siteLocale = $site->getLocale();
if (\is_string($siteLocale) && '' !== $siteLocale && $routeLocale !== $siteLocale) {
continue;
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this will change the behavior of route name with a . isn't it ?
This is BC break then

Should it be in a single pr with an option ?

Comment on lines +198 to +201
if ($this->denyCrossLocaleGenerate && null !== $siteLocale && '' !== $siteLocale && $hasSuffix && $routeLocale !== $siteLocale) {
// At this point, mismatch means user forced a different locale via _locale; allow it.
// To deny even forced cross-locale generation, uncomment the exception below.
// throw new RouteNotFoundException("Cross-locale generation denied for route '$name' on site locale '$siteLocale'");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unclear why we have an empty if here ; either you forgot to uncomment the exception or your can delete the whole if.

Comment on lines +57 to 63
// Do not rewrite the request pathInfo here.
// Keeping the original prefixed path allows Symfony's localized routing
// to differentiate locale variants and prevents cross-locale matches.
// (Previously: $request->setPathInfo($pathInfo ?? '/');)
if (null !== $this->site) {
$request->setPathInfo($pathInfo ?? '/');
// Intentionally left blank.
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is unclear to me:

  • I dont understand why it's needed
  • If we end up with an empty if, we just need to remove it
  • Can't it be considered as a BC break and could it be in a separate pr ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the HostPathByLocaleSiteSelector change actually do?

HostPathByLocaleSiteSelector::handleKernelRequest() is part of the site selection mechanism. In a “host+path” multisite setup, the selector typically:

looks at the incoming request path (e.g. /en/products)
determines which Site matches (e.g. site with relativePath="/en")
and then (this is the important part) it rewrites the Request pathInfo to remove the site prefix, so the rest of the app routes as if the site were at /.

Before (current bundle behavior)

If the site’s relativePath is /en and you request:

Incoming URL: /en/products

The selector finds the English site and then rewrites the request:

Request::getPathInfo() becomes /products (prefix stripped)

This helps CMS page lookup and “site-relative” matching, because Sonata pages are usually stored as /products, not /en/products.
After (your change in PR #1819)

You removed the rewrite:
PHP

// Previously: $request->setPathInfo($pathInfo ?? '/');

So the request path remains:

Request::getPathInfo() stays /en/products

Why that matters for Symfony localized routes

Symfony localized routes often differ by prefix:

English route path: /en/products
Finnish route path: /tuotteet (or /products without /en)

If the selector strips /en and turns the request into /products, then Symfony’s router will try to match /products and it may:

match the wrong locale route, or
match a neutral route, or
fall through in unexpected ways

Keeping /en/products intact is what allows “structural 404 for wrong locale” to work reliably: the router can see the locale prefix and refuse other locale variants.
Side effects / why maintainers will care

This is a behavior change for anyone using HostPathByLocaleSiteSelector, because other parts of the request lifecycle may have relied on the prefix-stripped pathInfo. For example:

CMS page router might now receive /en/... and fail unless it does its own normalization (which you added to CmsPageRouter::match()).
Any custom listener/controller logic reading Request::getPathInfo() may change behavior.

So effectively:

Old behavior: “pretend site is mounted at / by rewriting request”
New behavior: “keep the real URL; make routers handle site prefixes correctly”

Recommendation

If maintainers want a config flag, then this selector change should be enabled only when sonata_page.localized_routing.enabled is true (or moved together with the router changes), because on its own it can break existing setups.

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.

View page button in PageAdmin with multisite

3 participants