Every Laravel application with a JavaScript frontend has the same quiet bug factory in it: URLs typed by hand. Someone writes axios.post('/api/invoices/' + id + '/send'), someone else renames the route six months later, and nothing fails until a customer clicks the button. Blade apps have route() to protect them. Inertia, Vue, React, and Svelte frontends historically had nothing — or Ziggy, which gives you route names as strings and still lets you typo them.
Laravel Wayfinder closes that gap. It scans your routes and controllers and generates TypeScript functions — one per controller action — that your frontend imports. Rename a controller method, run the generator, and TypeScript tells you exactly which components broke. This tutorial sets it up on a real application, wires it into Vite and CI, and covers the parts the README skips: form helpers, query strings, conditional routes, and what to do when the generated output fights you.
You need Laravel 12 or newer with Vite, a TypeScript frontend, and route caching working (php artisan route:cache must succeed).
Install
composer require laravel/wayfinder
npm install --save-dev vite-plugin-run
Generate once to see what you get:
php artisan wayfinder:generate
This writes into resources/js/ by default:
resources/js/actions/— a function per controller action, grouped by controller namespace.resources/js/routes/— a function per named route, grouped by name segments.resources/js/wayfinder/— the small runtime helpers the generated code uses.
Add the first two to .gitignore or commit them; both are defensible. We commit them, because a reviewer reading a pull request can then see that a route contract changed, and because a fresh npm ci on a machine without PHP still type-checks.
Wire it into Vite
Regenerating by hand is the step everybody forgets. Run the generator whenever a route or controller file changes:
// vite.config.ts
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import run from 'vite-plugin-run';
export default defineConfig({
plugins: [
laravel({ input: ['resources/js/app.ts'], refresh: true }),
run([
{
name: 'wayfinder',
run: ['php', 'artisan', 'wayfinder:generate'],
pattern: ['routes/**/*.php', 'app/**/Http/**/*.php'],
},
]),
],
});
Now npm run dev keeps the definitions current, and npm run build regenerates before bundling.
What the generated code looks like
Given a plain resource controller:
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])->name('invoices.show');
Route::post('/invoices/{invoice}/send', [InvoiceController::class, 'send'])->name('invoices.send');
you get an import per action:
import InvoiceController from '@/actions/App/Http/Controllers/InvoiceController';
InvoiceController.show(invoice.id);
// { url: '/invoices/42', method: 'get' }
InvoiceController.send(invoice.id).url;
// '/invoices/42/send'
Each call returns an object with url and method, which is exactly the shape Inertia's router and the <Form> component want. Three details make this pleasant in practice:
Parameters are flexible. Pass a scalar, an object with the key, or a model-shaped object. All three work:
InvoiceController.show(42);
InvoiceController.show({ invoice: 42 });
InvoiceController.show(invoice); // { id: 42, ... }
If your route uses a custom key ({invoice:uuid}), Wayfinder reads it from the route definition and expects invoice.uuid.
Methods have helpers. .url(), .get(), .post(), .head() and friends are available on each action, so you can force a verb when a route registers several.
Named routes work too. If you prefer names over controller references, import from @/routes:
import { show, send } from '@/routes/invoices';
show(42).url; // '/invoices/42'
Both styles are generated; pick one per project and be consistent. We default to the actions/ style because "jump to definition" in the editor lands on the controller import, and because it survives renaming a route name.
Use it in Inertia
The payoff is in the components. With Inertia 2 and the <Form> component:
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import InvoiceController from '@/actions/App/Http/Controllers/InvoiceController';
defineProps<{ invoice: { id: number } }>();
</script>
<template>
<Form v-bind="InvoiceController.send(invoice.id)" #default="{ processing, errors }">
<button type="submit" :disabled="processing">Send invoice</button>
<p v-if="errors.email" class="text-red-600">{{ errors.email }}</p>
</Form>
</template>
v-bind spreads action and method together — no string, no route('invoices.send', ...) call that TypeScript cannot check. The same object works with imperative navigation:
import { router } from '@inertiajs/vue3';
router.visit(InvoiceController.show(invoice.id));
and with <Link>:
<Link :href="InvoiceController.show(invoice.id)">View</Link>
In React it is the same import with JSX:
import InvoiceController from '@/actions/App/Http/Controllers/InvoiceController';
<Link href={InvoiceController.show(invoice.id)}>View</Link>
Query strings
Every generated function takes an options argument for the query:
InvoiceController.index({ query: { status: 'overdue', page: 2 } }).url;
// '/invoices?status=overdue&page=2'
Use mergeQuery when you want to keep the current query string and change one key — the common case for filter UIs and sortable tables:
InvoiceController.index({ mergeQuery: { sort: '-due_at' } });
Passing null for a key removes it, which is how you clear a filter without string surgery.
Typed form requests
Wayfinder generates URLs, not payload types. For the request body, pair it with a types generator such as spatie/laravel-typescript-transformer or a hand-written resources/js/types/ module, and type your form state against it:
import type { InvoiceSendPayload } from '@/types/generated';
import { useForm } from '@inertiajs/vue3';
const form = useForm<InvoiceSendPayload>({ email: '', note: '' });
form.submit(InvoiceController.send(invoice.id));
Now both halves of the contract — the URL and the payload — break the build when the backend changes, which is the whole point.
Trimming the output
On a large application the generator produces a lot of files, most of which the frontend never imports. Two options in config/wayfinder.php (publish it with php artisan vendor:publish --tag=wayfinder-config):
return [
'skip' => [
'vendor', // ignore package-registered routes
],
'namespaces' => [
'App\\Http\\Controllers' => 'actions',
],
];
You can also generate a subset from the CLI:
php artisan wayfinder:generate --skip-routes # only actions/
php artisan wayfinder:generate --skip-actions # only routes/
Tree-shaking handles the rest: Vite only bundles the functions you actually import, so unused definitions cost build time, not bundle size.
Keep it honest in CI
Generated code drifts the moment someone commits a route change without running the generator. Make CI fail loudly:
- name: Generate Wayfinder definitions
run: php artisan wayfinder:generate
- name: Fail if definitions are stale
run: git diff --exit-code resources/js/actions resources/js/routes
- name: Type check
run: npx vue-tsc --noEmit # or tsc --noEmit for React
The git diff --exit-code step is the important one. Without it, a stale commit type-checks perfectly against yesterday's routes.
Things that will trip you up
- Closure routes are skipped. Wayfinder generates from controller actions and named routes; a
Route::get('/x', fn () => ...)without a name produces nothing. Give it a name or move it to a controller. - Duplicate method names across HTTP verbs. If one controller method is registered for both
GETandPOST, use the explicit.get()/.post()helpers rather than relying on the default. - Route model binding with non-default keys must be declared on the route (
{invoice:uuid}) or ingetRouteKeyName(). Wayfinder reads the route definition, so a binding resolved only inside the controller will generate the wrong parameter name. url()vs the object. Inertia's<Form>androuter.visit()want the whole object; a plain<a href>wants.url. Passing the object where a string is expected renders[object Object]— TypeScript catches it if your props are typed, so type them.- Signed and localised URLs are still server-side concerns. Generate those in the controller and pass them as props.
- Migrating from Ziggy does not have to be a big bang. Both can coexist; convert one page at a time, and delete Ziggy's
@routesdirective only when the lastroute()call is gone.
Is it worth it on an existing app?
On a Blade-heavy application, no — route() already gives you the same protection. On any application with a TypeScript frontend and more than a handful of endpoints, yes, and the install is an afternoon. The measurable win is that a class of bug which previously reached production (a renamed or removed endpoint that only fails at runtime, on one rarely-used screen) now fails in CI.
We install Wayfinder as part of most Inertia and Vue work, usually alongside a payload type generator so the entire frontend/backend contract is checked by the compiler. If you have a Laravel application with a JavaScript frontend that keeps breaking in small, embarrassing ways, our Laravel feature enhancement and consulting teams do exactly this kind of hardening — get in touch.