veltix/wayfinder-locales
Composer 安装命令:
composer require veltix/wayfinder-locales
包简介
Multilingual route + translation generation extending Laravel Wayfinder.
README 文档
README
Multilingual route + translation generation for Laravel Wayfinder.
It extends Wayfinder (it does not fork it) to support multilingual routes with translated
path segments — one logical route that resolves to a different URL per locale — and generates
type-safe frontend translation catalogs from your lang/ files. Works for any Wayfinder
frontend (React / Vue / Svelte).
home → / /de /fr
search → /search /de/suche /fr/recherche
listing → /listings/{l} /de/anzeigen/{l} /fr/annonces/{l}
How it works
A localized route is registered once per locale (named {locale}.{base}, with translated,
prefixed URLs). The generator collapses the per-locale variants into a single TypeScript
export whose definition.url is a locale → URL map, resolved at runtime against the active locale:
search.definition = { methods: ["get", "head"], url: { en: "/search", de: "/de/suche", fr: "/fr/recherche" }, defaultLocale: "en", } satisfies LocalizedRouteDefinition<["get", "head"]>; search.url(); // → "/de/suche" when the active locale is "de"
Because concrete routes are registered per locale (no runtime URL rewriting), route:cache
keeps working normally.
Installation
composer require veltix/wayfinder-locales npm install -D @veltixjs/vite-wayfinder-i18n
The service provider is auto-discovered. On boot it registers, for you:
- the
Route::localized()macro plus the->paths()/->segments()route macros, - the
setlocalemiddleware alias, - a locale-aware
UrlGenerator— plainroute('name')resolves to the active locale's variant (disable vialocale_aware_urls, see below), - the
wayfinder-i18n:generateandwayfinder-i18n:sync-segmentsartisan commands.
Publish the config:
php artisan vendor:publish --tag=wayfinder-i18n-config
Requirements: PHP 8.2+, Laravel 11/12/13,
laravel/wayfinder^0.1.14, and (for the Vite plugin)@laravel/vite-plugin-wayfinder+ Vite 7/8.
1. Config (config/wayfinder-i18n.php)
return [ // Locales you generate for. The first / `default` is the source of truth for // the generated `Locale` union and translation keys. 'locales' => ['en', 'de', 'fr'], 'default' => env('WAYFINDER_I18N_DEFAULT_LOCALE', 'en'), // When true, the default locale has no URL prefix (`/search`); others are // prefixed (`/de/suche`). When false, every locale is prefixed (`/en/search`). 'hide_default_prefix' => env('WAYFINDER_I18N_HIDE_DEFAULT_PREFIX', true), // The lang file (lang/{locale}/{lang_file}.php) holding translated URL segments. // This group is always excluded from the generated frontend catalogs. 'lang_file' => 'routes', // Keep plain `route('name')` locale-aware. Disable to manage locale URLs // manually (via lroute()) instead of swapping the `url` binding. 'locale_aware_urls' => env('WAYFINDER_I18N_LOCALE_AWARE_URLS', true), // Extra PHP lang groups (file basenames) to keep out of the frontend catalogs. 'exclude_groups' => [], ];
2. Register localized routes
Wrap the routes you want localized in Route::localized(). Each configured locale gets its own
concrete, prefixed, name-prefixed route:
use Illuminate\Support\Facades\Route; Route::middleware('setlocale')->group(function () { Route::localized(function () { Route::get('/', HomeController::class)->name('home'); Route::get('/search', SearchController::class)->name('search'); Route::get('/listings/{listing}', [ListingController::class, 'show'])->name('listings.show'); }); });
Leave auth/system routes (Fortify, webhooks, etc.) outside Route::localized() — they stay
unprefixed and the locale-aware route() falls back to them cleanly.
The setlocale middleware applies the matched route's locale default via app()->setLocale().
It only reads the route — for cookie / Accept-Language fallback on unprefixed URLs, use a richer
resolver (see below).
Translating route URLs — the two approaches
You translate the static URL segments of a route ({param} placeholders and the locale prefix
are never touched). There are two ways, and they compose — inline overrides win over the
dictionary, which falls back to the raw segment:
->paths() (whole path) › ->segments() (single segment) › lang dictionary › raw segment
Approach A — Shared segment dictionary (lang/{locale}/routes.php)
The default, global source. One file per locale mapping a segment to its translation, applied to every localized route automatically. Best for segments reused across many routes.
// lang/de/routes.php return [ 'search' => 'suche', 'listings' => 'anzeigen', 'about' => 'ueber-uns', ]; // lang/fr/routes.php return [ 'search' => 'recherche', 'listings' => 'annonces', ];
/listings/{listing} → /de/anzeigen/{listing}, /fr/annonces/{listing}. A segment with no
entry (e.g. about in French) falls back to the raw segment. The file name is the lang_file
config value (default routes) — on older Laravel it lives at resources/lang/{locale}/routes.php.
Use wayfinder-i18n:sync-segments to scaffold missing keys automatically (see Commands).
Approach B — Inline in the route definition (routes/web.php)
Override right next to the route. Best for one-off paths, multi-word slugs, or keeping a route's URLs beside its definition. Two macros:
Route::localized(function () { // (1) ->paths(): whole-path override, per locale. Give the path WITHOUT the // locale prefix; the prefix is re-applied for you. Overrides everything. Route::get('/listings/{listing}', [ListingController::class, 'show']) ->name('listings.show') ->paths([ 'de' => '/anzeigen/{listing}', 'fr' => '/annonces/{listing}', ]); // (2) ->segments(): override individual segments, leaving the rest to the // dictionary. Keyed by the source segment, then locale. Route::get('/help/contact', ContactController::class) ->name('help.contact') ->segments([ 'contact' => ['de' => 'kontakt', 'fr' => 'contact'], ]); });
Locales you omit from an override fall back to the dictionary, then the raw segment. Inline-handled
segments are excluded from sync-segments reporting, and route:cache is unaffected.
Tip: use the dictionary for your shared vocabulary and reach for inline overrides only where a route needs something special. They layer cleanly.
The Vite plugin (@veltixjs/vite-wayfinder-i18n)
Wraps @laravel/vite-plugin-wayfinder, points it at wayfinder-i18n:generate, and additionally
watches your lang/** files and config/wayfinder-i18n.php so localized routes and translation
catalogs regenerate whenever they change.
// vite.config.ts import { wayfinderI18n } from "@veltixjs/vite-wayfinder-i18n"; export default defineConfig({ plugins: [ laravel({ /* ... */ }), wayfinderI18n({ formVariants: true }), ], });
All upstream Wayfinder plugin options pass through. Additionally:
| Option | Default | Purpose |
|---|---|---|
command |
php artisan wayfinder-i18n:generate |
The artisan command run on start/change |
patterns |
merged with the defaults | Extra watch globs (routes/app/lang/config already watched) |
actions |
true |
Generate @/actions helpers |
routes |
true |
Generate @/routes helpers |
formVariants |
false |
Also emit .form variants |
path |
resources/js |
Output root |
Run npm run dev and the files under resources/js/{wayfinder,routes,actions,translations} stay
in sync as you edit routes and lang files.
Frontend integration
The generator emits these modules (under the @/ → resources/js alias):
| Module | Exports |
|---|---|
@/wayfinder |
runtime: setLocale, getLocale, resolveLocalizedUrl, queryParams, applyUrlDefaults, … |
@/wayfinder/locales |
type Locale, defaultLocale, locales[] |
@/routes, @/actions |
route/action helpers (localized routes collapsed to one locale-keyed export) |
@/translations |
t(), tChoice(), loadLocale() |
Always link with the generated helpers (
search.url(),listings.show.url({ listing })), never hardcoded path strings. Under translated routes a literal/searchwill 404 in non-default locales — the helper resolves to the active locale for you.
Step 1 — Share locale data from the server
Expose the active locale, the list of locales, and (crucially for the switcher) the current
page's URL in every locale. In Inertia, add to HandleInertiaRequests::share():
use Illuminate\Support\Str; 'locale' => app()->getLocale(), 'locales' => $this->locales(), // [['code' => 'en', 'label' => 'English'], ...] 'localeUrls' => $this->localeUrls($request),
/** Supported locales with native display labels. */ private function locales(): array { $labels = ['en' => 'English', 'de' => 'Deutsch', 'fr' => 'Français']; return collect(config('wayfinder-i18n.locales', ['en'])) ->map(fn (string $code) => ['code' => $code, 'label' => $labels[$code] ?? strtoupper($code)]) ->values()->all(); } /** * The current page's URL in each locale, for the language switcher. Localized * routes are re-resolved per locale via lroute(); other routes keep the URL. */ private function localeUrls(\Illuminate\Http\Request $request): array { $locales = (array) config('wayfinder-i18n.locales', ['en']); $route = $request->route(); $marker = $route?->defaults['locale'] ?? null; if (! $route || ! is_string($marker)) { return collect($locales)->mapWithKeys(fn ($l) => [$l => $request->fullUrl()])->all(); } // Strip the "{locale}." name prefix to get the base route name. $base = Str::after((string) $route->getName(), $marker.'.'); // Route defaults leak the 'locale' marker into parameters(); it's the URL // prefix, not a real param, so drop it to avoid a spurious ?locale=… query. $params = array_merge($route->parameters(), $request->query()); unset($params['locale']); return collect($locales) ->mapWithKeys(fn (string $l) => [$l => lroute($base, $params, $l)]) ->all(); }
lroute($name, $params, $locale) (shipped as a global helper) generates a URL for a specific
locale's variant, falling back to the unprefixed route when none exists — ideal for switchers and
sitemaps.
Step 2 — A shared locale helper (framework-agnostic)
Put the runtime wiring in one adapter-neutral module. It points the route helpers at the live
locale, keeps the active translation chunk loaded across navigations, and exposes switchLocale.
It imports router from @inertiajs/core, so it's identical for React, Vue, and Svelte:
// resources/js/lib/locale.ts import { router } from "@inertiajs/core"; import { setLocale as setWayfinderLocale } from "@/wayfinder"; import { loadLocale } from "@/translations"; import { defaultLocale, type Locale } from "@/wayfinder/locales"; let current: Locale = defaultLocale; /** Call once at startup with the initial locale (from the first Inertia page). */ export function initializeLocale(initial: Locale): void { current = initial ?? defaultLocale; setWayfinderLocale(() => current); // route helpers (.url()) follow the active locale void loadLocale(current); router.on("navigate", (e) => { current = ((e.detail.page.props.locale as Locale) ?? defaultLocale); void loadLocale(current); // preload the new page's catalog chunk }); } /** * Switch language: persist the choice, load the target catalog, then visit the * current page's URL in that locale (from the shared `localeUrls`), which * re-renders server-side in the new locale. */ export function switchLocale(target: Locale, localeUrls: Record<string, string>): void { if (target === current) return; // `document` is undefined during SSR — guard the cookie write. switchLocale // is user-triggered so it runs client-side, but the guard keeps the module // safe to import from server-rendered code. if (typeof document !== "undefined") { document.cookie = `locale=${target};path=/;max-age=${365 * 24 * 60 * 60};SameSite=Lax`; } void loadLocale(target); const url = localeUrls[target]; if (url) router.visit(url, { preserveScroll: true, preserveState: false }); }
SSR note: if you run Inertia SSR,
document/windowdon't exist on the server, so any browser-only access (like the cookie write above) must be guarded withtypeof document !== "undefined".initializeLocale()similarly registers arouter.on("navigate")listener that only fires in the browser — calling it from the SSR entry is harmless, but the cookie and navigation effects only take hold client-side. To make the first server render pick the right locale, rely on the request-side middleware (route → cookie →Accept-Language), not the client cookie.
Then call initializeLocale() in your app entry, reading the initial locale from
props.initialPage (available in every adapter's setup):
React — resources/js/app.tsx
import { createInertiaApp } from "@inertiajs/react"; import { createRoot } from "react-dom/client"; import { initializeLocale } from "@/lib/locale"; import { defaultLocale, type Locale } from "@/wayfinder/locales"; createInertiaApp({ resolve: (name) => /* ...your page resolver... */, setup({ el, App, props }) { initializeLocale((props.initialPage.props.locale as Locale) ?? defaultLocale); createRoot(el).render(<App {...props} />); }, });
Vue — resources/js/app.ts
import { createInertiaApp } from "@inertiajs/vue3"; import { createApp, h } from "vue"; import { initializeLocale } from "@/lib/locale"; import { defaultLocale, type Locale } from "@/wayfinder/locales"; createInertiaApp({ resolve: (name) => /* ...your page resolver... */, setup({ el, App, props, plugin }) { initializeLocale((props.initialPage.props.locale as Locale) ?? defaultLocale); createApp({ render: () => h(App, props) }).use(plugin).mount(el); }, });
Svelte — resources/js/app.ts
import { createInertiaApp } from "@inertiajs/svelte"; import { mount } from "svelte"; import { initializeLocale } from "@/lib/locale"; import { defaultLocale, type Locale } from "@/wayfinder/locales"; createInertiaApp({ resolve: (name) => /* ...your page resolver... */, setup({ el, App, props }) { initializeLocale((props.initialPage.props.locale as Locale) ?? defaultLocale); mount(App, { target: el, props }); }, });
Step 3 — Build a language switcher
Read locale / locales / localeUrls from your shared props and call switchLocale. Same
component in each framework:
React — LanguageSwitcher.tsx
import { usePage } from "@inertiajs/react"; import { switchLocale } from "@/lib/locale"; import type { Locale } from "@/wayfinder/locales"; type LocaleOption = { code: Locale; label: string }; export default function LanguageSwitcher() { const { locale, locales, localeUrls } = usePage().props as { locale: Locale; locales: LocaleOption[]; localeUrls: Record<string, string>; }; return ( <div className="lang-switcher"> {locales.map((opt) => ( <button key={opt.code} type="button" aria-current={locale === opt.code} onClick={() => switchLocale(opt.code, localeUrls)} > {opt.label} {locale === opt.code ? " ✓" : ""} </button> ))} </div> ); }
Vue — LanguageSwitcher.vue
<script setup lang="ts"> import { usePage } from "@inertiajs/vue3"; import { switchLocale } from "@/lib/locale"; const page = usePage(); </script> <template> <div class="lang-switcher"> <button v-for="opt in page.props.locales" :key="opt.code" type="button" :aria-current="page.props.locale === opt.code" @click="switchLocale(opt.code, page.props.localeUrls)" > {{ opt.label }}<span v-if="page.props.locale === opt.code"> ✓</span> </button> </div> </template>
Svelte — LanguageSwitcher.svelte
<script lang="ts"> import { page } from "@inertiajs/svelte"; import { switchLocale } from "@/lib/locale"; const active = $derived($page.props.locale); const locales = $derived($page.props.locales ?? []); const urls = $derived($page.props.localeUrls ?? {}); </script> <div class="lang-switcher"> {#each locales as opt (opt.code)} <button type="button" aria-current={active === opt.code} onclick={() => switchLocale(opt.code, urls)} > {opt.label}{#if active === opt.code} ✓{/if} </button> {/each} </div>
Locale resolution middleware
The bundled setlocale middleware only applies a matched route's locale. For a complete
resolver — so unprefixed default-locale pages and API calls still pick the right language — register
your own middleware in the web group that layers route → cookie → Accept-Language → default:
namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cookie; class SetLocale { public function handle(Request $request, Closure $next) { $supported = (array) config('wayfinder-i18n.locales', ['en']); $default = (string) config('wayfinder-i18n.default', 'en'); $locale = $this->resolve($request, $supported, $default); app()->setLocale($locale); $response = $next($request); Cookie::queue('locale', $locale, 60 * 24 * 365); // persist for unprefixed nav return $response; } private function resolve(Request $request, array $supported, string $default): string { $route = $request->route()?->defaults['locale'] ?? null; if (is_string($route) && in_array($route, $supported, true)) return $route; $cookie = $request->cookie('locale'); if (is_string($cookie) && in_array($cookie, $supported, true)) return $cookie; $preferred = $request->getPreferredLanguage($supported); if (is_string($preferred) && in_array($preferred, $supported, true)) return $preferred; return $default; } }
Register it in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [\App\Http\Middleware\SetLocale::class]); })
Translations
Beyond routes, the generator turns lang/{locale}/*.php (flattened to dot keys) and
lang/{locale}.json into type-safe frontend catalogs. The default locale is the source of truth
for the generated key set, and each locale is a lazy, code-split chunk.
import { t, tChoice } from "@/translations"; t("search.title"); // "Ausrüstung suchen" (de) t("search.greeting", { name: "Ada" }); // ":name" → "Ada", with :Name / :NAME case variants tChoice("search.results", 5); // Laravel-style pluralization ("|", {0}, [1,*])
t() is keyed by a generated TranslationKey union (typos are compile errors) and requires the
right replacement object per key via TranslationReplacements. Exclude groups from the catalogs
with exclude_groups in the config (the route-segment file is always excluded).
Commands
php artisan wayfinder-i18n:generate {--path=} {--skip-actions} {--skip-routes} {--with-form} {--skip-translations}
Generates the route/action helpers and translation catalogs. Usually you don't run it by hand — the Vite plugin runs it on dev start and on change.
php artisan wayfinder-i18n:sync-segments {--locale=*} {--dry-run}
Scans registered localized routes and scaffolds any missing segment keys into
lang/{locale}/{lang_file}.php as 'segment' => 'segment', // TODO: translate stubs, so newly
added routes don't silently fall back to raw segments. By default it only touches locales that
already have a segment file (pass --locale=xx to create one); existing entries and formatting are
preserved, and unused keys are reported but never removed. Segments handled by an inline
->paths() / ->segments() override are skipped.
Source segments are collected as routes register, so run it with un-cached routes
(php artisan route:clear first if needed).
License
MIT
veltix/wayfinder-locales 适用场景与选型建议
veltix/wayfinder-locales 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 86 次下载、GitHub Stars 达 0, 最近一次更新时间为 2026 年 02 月 10 日, 在 PHP 生态内属于活跃度较高的组件。
它主要适用于以下技术方向: 「i18n」 「routes」 「laravel」 「localization」 「typescript」 「wayfinder」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。
我们在过去多个企业项目中使用过 veltix/wayfinder-locales 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。
基于 veltix/wayfinder-locales 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。
线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。
承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。
与 veltix/wayfinder-locales 相关的其它包
同方向 / 同关键字的高下载量 PHP Composer 包推荐,方便对比选型:
Generates a Blade directive exporting all of your named Laravel routes. Also provides a nice route() helper function in JavaScript.
Easy to use i18n translation PHP class for multi-language websites
Block routes by IP
A custom URL rule class for Yii 2 which allows to create translated URL rules
Provides caching methods which can be easily used for caching routes.
Bureaux A Partager Edit - Parse, validate, manipulate, and display dates in PHP w/ i18n support. Inspired by moment.js
统计信息
- 总下载量: 86
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 39
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-02-10