PHPackages                             veltix/wayfinder-locales - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Localization &amp; i18n](/categories/localization)
4. /
5. veltix/wayfinder-locales

ActiveLibrary[Localization &amp; i18n](/categories/localization)

veltix/wayfinder-locales
========================

Multilingual route + translation generation extending Laravel Wayfinder.

v2.0.1(yesterday)089↓91.1%MITPHPPHP ^8.2

Since Feb 10Pushed yesterdayCompare

[ Source](https://github.com/veltix/wayfinder-locales)[ Packagist](https://packagist.org/packages/veltix/wayfinder-locales)[ RSS](/packages/veltix-wayfinder-locales/feed)WikiDiscussions main Synced yesterday

READMEChangelog (5)Dependencies (17)Versions (7)Used By (0)

veltix/wayfinder-locales
========================

[](#veltixwayfinder-locales)

Multilingual route + translation generation for [Laravel Wayfinder](https://github.com/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
------------

[](#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;

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
------------

[](#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 `setlocale` middleware alias,
- a **locale-aware `UrlGenerator`** — plain `route('name')` resolves to the active locale's variant (disable via `locale_aware_urls`, see below),
- the `wayfinder-i18n:generate` and `wayfinder-i18n:sync-segments` artisan 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`)

[](#1-config-configwayfinder-i18nphp)

```
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

[](#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](#locale-resolution-middleware)).

---

Translating route URLs — the two approaches
-------------------------------------------

[](#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`)

[](#approach-a--shared-segment-dictionary-langlocaleroutesphp)

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](#commands)).

### Approach B — Inline in the route definition (`routes/web.php`)

[](#approach-b--inline-in-the-route-definition-routeswebphp)

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`)
-------------------------------------------------

[](#the-vite-plugin-veltixjsvite-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:

OptionDefaultPurpose`command``php artisan wayfinder-i18n:generate`The artisan command run on start/change`patterns`merged with the defaultsExtra 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 rootRun `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
--------------------

[](#frontend-integration)

The generator emits these modules (under the `@/` → `resources/js` alias):

ModuleExports`@/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 `/search` will 404 in non-default locales — the helper resolves to the active locale for you.

### Step 1 — Share locale data from the server

[](#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)

[](#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): 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`/`window` don't exist on the server, so any browser-only access (like the cookie write above) must be guarded with `typeof document !== "undefined"`. `initializeLocale()` similarly registers a `router.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();
    },
});
```

**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

[](#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;
    };

    return (

            {locales.map((opt) => (
                 switchLocale(opt.code, localeUrls)}
                >
                    {opt.label}
                    {locale === opt.code ? " ✓" : ""}

            ))}

    );
}
```

**Vue** — `LanguageSwitcher.vue````

import { usePage } from "@inertiajs/vue3";
import { switchLocale } from "@/lib/locale";

const page = usePage();

            {{ opt.label }} ✓

```

**Svelte** — `LanguageSwitcher.svelte````

    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 ?? {});

    {#each locales as opt (opt.code)}
         switchLocale(opt.code, urls)}
        >
            {opt.label}{#if active === opt.code} ✓{/if}

    {/each}

```

### Locale resolution middleware

[](#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
------------

[](#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
--------

[](#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
-------

[](#license)

MIT

###  Health Score

44

—

FairBetter than 90% of packages

Maintenance100

Actively maintained with recent releases

Popularity10

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity51

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~28 days

Recently: every ~35 days

Total

6

Last Release

1d ago

Major Versions

v0.0.4 → v2.0.02026-07-02

PHP version history (2 changes)v0.0.1PHP ^8.2

v0.0.4PHP ^8.3

### Community

Maintainers

![](https://www.gravatar.com/avatar/f2bc1d8551638205f4ec624ec9bd3619e16f0dd94baa576246eaf90d08f631c8?d=identicon)[Veltix](/maintainers/Veltix)

---

Top Contributors

[![veltix](https://avatars.githubusercontent.com/u/46252663?v=4)](https://github.com/veltix "veltix (9 commits)")

---

Tags

laravellocalizationi18nroutestypescriptwayfinder

###  Code Quality

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/veltix-wayfinder-locales/health.svg)

```
[![Health](https://phpackages.com/badges/veltix-wayfinder-locales/health.svg)](https://phpackages.com/packages/veltix-wayfinder-locales)
```

###  Alternatives

[laravel/ai

The official AI SDK for Laravel.

1.0k3.2M194](/packages/laravel-ai)[erag/laravel-lang-sync-inertia

A powerful Laravel package for syncing and managing language translations across backend and Inertia.js (Vue/React/Svelte) frontends, offering effortless localization, auto-sync features, and smooth multi-language support for modern Laravel applications.

4925.3k](/packages/erag-laravel-lang-sync-inertia)[laravel/wayfinder

Generate TypeScript representations of your Laravel actions and routes.

1.8k8.6M132](/packages/laravel-wayfinder)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9762.4M131](/packages/roots-acorn)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

77022.3M151](/packages/laravel-mcp)[laravel/folio

Page based routing for Laravel.

603583.7k33](/packages/laravel-folio)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
