Need help understanding/configuring complex routing (or redirect?) for multilanguage urls

Hi !
I have built several Kirby websites in the past months and it’s been a breeze :+1:.
Now the main thing I have really struggled with has been routing. Seems like I can’t wrap my head around it.

I’m trying to change a few URLs in the structure of my website.
Context: it’s v4 website, using multilanguage configuration for english (en) and french (fr). I have two collections: products, and blog posts, alongside normal pages. I have the Retour plugin installed.

The first question I guess is : is routing the good thing to use or should I just redirects in Retour? I feel like routing is a more maintainable way of doing it. Also I’d love to understand routing. My default language is configured to not show the /fr/ in the url.

Currently I have: in FR:

  • example.com/produits/ => it’s the shop landing page
  • example.com/produits/nom-du-produit => an individual product
  • example.com/articles/ => the blog page with the list of latest publications
  • example.com/articles/un-titre-article-de-blog => an individual article

Those urls are translated as in EN;

  • example.com/en/products/
  • example.com/en/products/product-name
  • example.com/en/news/
  • example.com/en/news/a-blog-post-title

Here is what I’m trying to do:

Parent pages and their translation stay the same

  • example.com/produits/ stays the same, plural
  • example.com/articles/ same

And then I want to change:
Product is still a child of /produits/ but the url is singular. Similar in english:

  • example.com/produits/nom-du-produit => example.com/produit/nom-du-produit
  • example.com/en/products/product-name => example.com/en/product/product-name

Blog posts are still children of /articles/ and /en/news but site in the root url:

  • example.com/articles/un-article-de-blog => example.com/un-article-de-blog
  • example.com/en/news/a-blog-post-title => example.com/en/a-blog-post-title

For those who wonder why I’d do that, it’s an old website with years of history and we’d like to reproduce the url structure from the previous website (wordpress). If that’s the only way/best way I can concede to putting back the blog posts in the root folder but i’d rather not.

Thanks in advance a lot for any guidance about the errors I could have made…

Here is what I tried but I get 404s.
For example for a singular product I get 404 on /vin/product-name and on /vins/product-name
I tried to use the docs, the examples and even code llm but to no effect. I get 404s all around in a way or another or with only one of the cases working.

// /site/config/config.php
'routes' => [
        [
            // Route to change the French product URL from plural to singular
            'pattern' => 'produits/(:any)',  // Match any product under /produits/
            'language' => 'fr',               // Apply only for the French version
            'action' => function ($productSlug) {
                // Redirect to the singular /produit/ slug
                $productPage = page('produits/' . $productSlug);
                if ($productPage) {
                    return $productPage;
                }
                return false; // Return false to allow Kirby to show a 404 error
            }
        ],
        [
            // Route to change the English product URL from plural to singular
            'pattern' => 'en/products/(:any)',
            'language' => 'en',               // Apply only for the English version
            'action' => function ($productSlug) {
                $productPage = page('en/products/' . $productSlug);
                if ($productPage) {
                    return $productPage;
                }
                return false;
            }
        ],
        // Route to remove the parent "articles" slug from French blog posts
            [
              'pattern' => '(:any)',
              'language' => 'fr',
              'action'  => function($slug) {
                  // First, try to find the page at the root level (e.g., /un-article-de-blog)
                  $page = page($slug);
                  
                  // If not found at the root, try to find it under the "articles" parent page
                  if(!$page) {
                      $page = page('articles/' . $slug);
                  }
  
                  // If still not found, show the error page
                  if (!$page) {
                      $page = site()->errorPage();
                  }
  
                  // Visit the resolved page (either root, articles, or error page)
                  return site()->visit($page);
              }
          ],
          // Route to handle redirects for old French URLs (e.g., /articles/un-article-de-blog => /un-article-de-blog)
          [
              'pattern' => 'articles/(:any)',
              'language' => 'fr',
              'action'  => function($slug) {
                  // Redirect to the new root-level URL
                  go('/' . $slug);
              }
          ],
          
          // Route to remove the parent "news" slug from English blog posts
          [
              'pattern' => 'en/(:any)',
              'language' => 'en',
              'action'  => function($slug) {
                  // First, try to find the page at the root level (e.g., /en/a-blog-post-title)
                  $page = page('en/' . $slug);
                  
                  // If not found at the root, try to find it under the "en/news" parent page
                  if(!$page) {
                      $page = page('en/news/' . $slug);
                  }
  
                  // If still not found, show the error page
                  if (!$page) {
                      $page = site()->errorPage();
                  }
  
                  // Visit the resolved page (either root, en/news, or error page)
                  return site()->visit($page);
              }
          ],
          // Route to handle redirects for old English URLs (e.g., /en/news/a-blog-post-title => /en/a-blog-post-title)
          [
              'pattern' => 'en/news/(:any)',
              'language' => 'en',
              'action'  => function($slug) {
                  // Redirect to the new root-level URL for English articles
                  go('/en/' . $slug);
              }
          ],
    ]

Here is my fr.php config

// /site/languages/fr.php
return [
    'code' => 'fr',
    'default' => true,
    'direction' => 'ltr',
    'locale' => [
        'LC_ALL' => 'fr_FR'
    ],
    'name' => 'Français',
    'url' => '/'
];

Maybe I’m trying to do too many things at once.
Starting over and reading this and other threads I have solved the articles problem using these routes and a model:

// site/config/config.php
    'routes' => [
      [
        'pattern' => '(:any)',
        'language'=> 'fr',
        'action'  => function($language, $uid) {
            $page = page($uid);
            if(!$page) $page = page('articles/' . $uid);
            if(!$page) $page = site()->errorPage();
            return site()->visit($page);
        }
      ],
      [
        'pattern' => 'articles/(:any)',
        'language' => 'fr',
        'action'  => function($language, $uid) {
            go($uid);
        }
      ],

      // Route for English articles (news)
      [
        'pattern' => '(:any)', // Match any UID for English
        'language' => 'en',
        'action'  => function($language, $uid) {
            $page = page($uid); // Check for a direct page match
            if (!$page) $page = page('news/' . $uid); // Check for English articles
            if (!$page) $page = site()->errorPage(); // Fallback to the error page if not found
            return site()->visit($page); // Visit the found page
        }
      ],
      [
        'pattern' => 'news/(:any)',
        'language' => 'en',
        'action'  => function($language, $uid) {
            go($uid);
        }
      ],
    ]
// site/models/article.php
use Kirby\Cms\Page;


class ArticlePage extends Page {
    
    public function url($options = null): string {
        
        $languageCode = $this->kirby()->language() ? $this->kirby()->language()->code() : '';
        
// because my website is configured not to have the /fr/ for the default language
        if ( $languageCode == 'fr') {
            return url( $this->slug( ) );

        } else {
            return url( $languageCode . '/' . $this->slug( ) );

        }

    }

}

Now I still could not manage to route the /products/ urls and have another bug, the language switcher link populated with this code does not work anymore on articles/ or /news/ pages: the link sends to the current page.

<a class="languageswitcher" 
      href="<?php e($page->translation($language->code())->exists(), $page->url($language->code()), page('error')->url($language->code())) ?>">

What is your current code state for this? What is the result you get and what is the result you want?

Since you use the $page variable, the links always point to the current page. Isn’t this what you intended?

Hi! thanks for answering. Sorry for the delay I was under a huge backlog of work.

Here is the current state @lukasbestle

Routes for products/produits and produit/product:

'routes' => [

      /**
       * REDIRECT /product URL to /products page
       */
      [
        'pattern' => 'produit',
        'language'=> 'fr',
        'action'  => function () {
          go('produits');
        }
      ],
      // same for english
      [
        'pattern' => 'product',
        'language'=> 'en',
        'action'  => function () {
          go('products');
        }
      ],

      /**
       * ROUTE PRODUCT PAGES to SINGULAR URL /products/ to /product/
       */
      [
        'pattern' => 'produits/(:any)', // Match URLs like /products/slug
        'language'=> 'fr',
        'action'  => function ($language, $slug) {
            // Find the page under /products/ with the given slug
            $productPage = page('produits/' . $slug);
            if ($productPage) {
                // Redirect to the new URL: /product/slug
                return go('produit/' . $slug);
            }
            return go('error');
        }
    ],
    [
        'pattern' => 'produit/(:any)', // Match URLs like /product/slug
        'language'=> 'fr',
        'action'  => function ($language, $slug) {
            // Find the product page based on the new pattern
            return page('produits/' . $slug) ?? go('error');
        }
      ],
      // same for english language
      [
        'pattern' => 'products/(:any)',
        'language'=> 'en',
        'action'  => function ($language, $slug) {
            $productPage = page('products/' . $slug);
            if ($productPage) {
                return go('en/product/' . $slug);
            }
            return go('error');
        }
    ],
    [
        'pattern' => 'product/(:any)',
        'language'=> 'en',
        'action'  => function ($language, $slug) {
            return page('products/' . $slug) ?? go('error');
        }
      ],

and route for articles/news

      /**
       * REDIRECT /articles/blog-post to /blog-post
       */
      [
        'pattern' => '(:any)',
        'language'=> 'fr',
        'action'  => function($language, $uid) {
            $page = page($uid);
            if(!$page) $page = page('articles/' . $uid);
            if(!$page) $page = site()->errorPage();
            return site()->visit($page);
        }
      ],
      [
        'pattern' => 'articles/(:any)',
        'language' => 'fr',
        'action'  => function($language, $uid) {
            go($uid);
        }
      ],
      // Same for English articles /en/news/blog-post
      [
        'pattern' => '(:any)', // Match any UID for English
        'language' => 'en',
        'action'  => function($language, $uid) {
            $page = page($uid); // Check for a direct page match
            if (!$page) $page = page('news/' . $uid); // Check for English articles
            if (!$page) $page = site()->errorPage(); // Fallback to the error page if not found
            return site()->visit($page); // Visit the found page
        }
      ],
      [
        'pattern' => 'news/(:any)',
        'language' => 'en',
        'action'  => function($language, $uid) {
            go($uid);
        }
      ],
    ]

And the article.php model page

<?php

// site/models/article.php
use Kirby\Cms\Page;

class ArticlePage extends Page {
    public function url($options = null): string {
        $languageCode = $this->kirby()->language() ? $this->kirby()->language()->code() : '';
        // because my website is configured not to have the /fr/ for the default language
        if ( $languageCode == 'fr') {
            return url( $this->slug( ) );
        } else {
            return url( $languageCode . '/' . $this->slug( ) );
        }
    }
}

There is obviously something wrong somewhere but I still could not figure it out.
Seems like I need the article.php model to make it work but it is buggy for still the same reason as stated before.

What I meant when I was saying that the link points to the current page I meant the exact current page, not the translation of the current page.
There is something I can’t manage to wrap my head around in there.
Reading the code for $page didn’t really solve it for me.

When on an english news article, which url is, thanks to the routes example.com/en/blog-post-url, the language switcher points to this same url instead of example.com/post-de-blog-url.

Maybe I should simply get rid of it, but then I don’t see how to properly setup the Routing. Without the page model, when I’m on a french post page and want to switch to english, or when I’m on the /news/ page and, the link of an english post is redirected from example.com/en/news/blog-post-url to example.com/blog-post-url which is a 404 naturally.

It could be that the calls to url() inside your page model break the URLs you intend. The url() helper contains some magic to convert page URIs to URLs dynamically, potentially reversing the effect you intend.

Instead, I’d recommend to use something like:

class ArticlePage extends Page {
	public function urlForLanguage(
		$language = null,
		array $options = null
	): string {
		if ($options !== null) {
			return parent::urlForLanguage($language, $options);
		}

		return $this->url = $this->site()->urlForLanguage($language) . '/' . $this->slug($language);
	}
}

Indeed, that’s better! Thank you very much, I’m not sure I could have figured that myself.
The rest is, I think, working like I intended it to.

I detected another issue while adding another language. A very weird issue in fact.
I’m not sure it is 100% related to this, there is possibly another bug under there.

These bits of code seem to be flipping around with new added language.
Something very very strange that I don’t understand is that adding a language like Deutsch (/de) seems to work properly: I’ve added this language through the panel menu and adding a language, then translating only the homepage. The only *.de.txt file in my entire site is home.de.txt.
It works when I visit it.

Then I added like Chinese (zh_CN) and Italian (it_IT) and then the homepage redirects to error 404 page.

it.php, zh.php and de.php all have the default content.

If I comment out those two bits, it works again. So maybe I need to make those routes more versatile. But, still, I don’t understand the different behavior betwen deutsch and other languages.

      [
        'pattern' => '(:any)',
        'language'=> 'fr',
        'action'  => function($language, $uid) {
            $page = page($uid);
            if(!$page) $page = page('articles/' . $uid);
            if(!$page) $page = site()->errorPage();
            return site()->visit($page);
        }
      ],
//[...]
      // Same for English articles /en/news/blog-post
      [
        'pattern' => '(:any)', // Match any UID for English
        'language' => 'en',
        'action'  => function($language, $uid) {
            $page = page($uid); // Check for a direct page match
            if (!$page) $page = page('news/' . $uid); // Check for English articles
            if (!$page) $page = site()->errorPage(); // Fallback to the error page if not found
            return site()->visit($page); // Visit the found page
        }
      ]

Furthermore, the combined route and Models/article.php file makes the previewing of draft article impossible :confused:

The issue is the same that the one described by several users here:

Returning pages from routes and overriding the url() method are two implementations that modify Kirby’s behavior in a pretty far-reaching way. Kirby is really flexible with these things and allows many customizations, but we cannot think of all modifications in our code ahead of time and we also cannot support each customization.

If you prefer a robust and reliable setup, it should be much simpler to move the pages to their intended “real” paths and create redirects for the old URLs (be it with custom routes or Retour). If you really need to implement the singular/plural URLs without redirects, a compromise could be to make the singular version the “real” path and only create a single fixed route for the overview page under the plural name.

I totally understand. Can’t anticipate every weird edge case clients or devs could want to modify the site into :sweat_smile:

Coming back to structuring files as we want our URLs to end up being represented was my conclusion after our back and forth discussions.

Thanks a lot for taking the time to guide me through those issues.