Translating a Laravel app is two problems, and only one of them is about words

This is me

This is me

"Make the app multilingual" sounds like one task. It is two, and conflating them is why multilingual Laravel apps tend to grow a layer of if (app()->getLocale() === 'es') scattered through the views.

The first problem is the text on the page. That is a string lookup, and Laravel has an answer for it.

The second is the URLs. English users should get /dashboard, Spanish users /es/panel, and both should be actual routes: they resolve to a controller, they appear in route(), they show up in the route list, they work with route model binding. This half has nothing to do with translating text and everything to do with the router.

Once you see them as separate problems, the design of the second one gets much clearer.

The decision not to rewrite URLs at runtime

The tempting shortcut is middleware that inspects the incoming path, maps /es/panel back to /dashboard, and lets the normal router take it from there. It is maybe forty lines and it demos well.

It also degrades everything downstream, because the route never really existed:

  • route('dashboard') cannot generate the Spanish URL. It knows one path, and it is the English one, so you end up hand-building localized links.
  • The route list, and anything reading it, only ever sees English. Ziggy exports the English path to JavaScript and your frontend links are all wrong.
  • Route caching works against you: it caches the pre-rewrite routes, so the mapping stays in a middleware that runs on every request.
  • Two locales that need genuinely different path shapes cannot be expressed at all.

So LocalizedRoute::get registers a real route per locale at definition time. /dashboard and /es/panel both exist in the router, both can be cached, both are visible to Ziggy. The cost is more entries in the route table, which costs nothing that matters. The gain is that every mechanism built on top of the router keeps working, because there is nothing special to know.

The decision about names

Real routes per locale means real names per locale, so each one is {locale}.{name}, and route('es.dashboard') targets Spanish explicitly. You need that: a language switcher has to name the language it is switching to.

But if that were the only form, every link in every template would have to interpolate the current locale, which is the scattered-conditionals problem wearing a different hat.

So a bare route('dashboard') resolves to the current locale's version. Templates written with no awareness of language do the right thing, and the explicit form is there when the intent really is "this specific language". Two ways to name the same route, each for a different intent, rather than one way that is wrong half the time.

The decision to keep detection out of the router

A localized URL only helps if the app actually switches locale, and my first instinct was to have the route do it: it knows its own locale, so it could just set it.

That is wrong in a way that is not obvious until you have a real app. The public site wants the URL prefix to win, because the URL is shareable and should be authoritative. An admin panel wants the opposite: the user picked a language in their settings once and every URL should respect that, prefix or not. A marketing landing page might want Accept-Language on a cold first visit.

Those are three different policies over the same routes, so the policy cannot live in the route. It lives in middleware, one per strategy, plus a combined one that tries the URL prefix first and falls back to session or browser. You attach the strategy per route group, which means the public site and the panel can each get the behaviour that fits them without knowing about each other.

Where the split pays off

The public site of one of my apps runs entirely on single LocalizedRoute definitions listing ['en', 'es', 'cn'], locale coming from the URL prefix. The internal panel uses the session strategy, because there the language is a user setting, not a property of the link.

Neither half knows about the other, and no template branches on the current language. That is the whole return on treating URLs and text as two problems: Laratext never has to know a route exists, and Laralang never has to know what a page says.

Full route syntax, the middleware table and configuration are on the Laralang page. Source on GitHub, package on Packagist.