承接 veltix/wayfinder-locales 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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

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 /search will 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/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):

Reactresources/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} />);
    },
});
Vueresources/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);
    },
});
Svelteresources/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:

ReactLanguageSwitcher.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>
    );
}
VueLanguageSwitcher.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>
SvelteLanguageSwitcher.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 我们能提供哪些服务?
定制开发 / 二次开发

基于 veltix/wayfinder-locales 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。

BUG 修复 & 性能优化

线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。

项目外包 & 长期维护

承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。

yvsm@zunyunkeji.com QQ:316430983 微信:yvsm316 西安尊云信息科技 · 专注 PHP / Go / 分布式系统研发

统计信息

  • 总下载量: 86
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 39
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-02-10