Cleaner Routing with Wayfinder
Generate type-safe frontend routes from Laravel controllers with zero friction.

Laravel Wayfinder bridges your Laravel backend and TypeScript frontend with zero friction. It automatically generates fully-typed, importable TypeScript functions for your controllers and routes — so you can call your Laravel endpoints directly in your client code just like any other function. No more hardcoding URLs, guessing route parameters, or syncing backend changes manually.
If you've ever cursed at a hardcoded URL in a Vue or React component, you are not alone. Laravel provides lovely routing on the backend, but syncing those routes with the frontend has been a manual and error-prone disaster.
Until now.
Meet Laravel Wayfinder, the new first-party package that bridges your Laravel backend and TypeScript frontend seamlessly. It automatically generates fully typed and portable route and action references, eliminating the need to hardcode URLs or route names in your frontend code.
Why Wayfinder?
When dealing with frontend frameworks such as React or Vue, you frequently need to connect to backend routes or controller actions. However, these routes and named routes are not directly accessible from your frontend, resulting in hardcoded URLs that can be difficult to manage.
Wayfinder kills this pain with one command:
php artisan wayfinder:generate
How it Works
First you need to install Wayfinder on your Laravel application. You can do that by running this command:
composer require laravel/wayfinder
Now, you'll need to generate the definitions with the php artisan wayfinder:generate command listed above and you can find them on your resources/js/actions directory.
Let's suppose we have a Post model and controller, one of them will be at resources/js/actions/App/Http/Controllers/PostController.ts, and will look like the following:
import { queryParams, type QueryParams } from './../../../../wayfinder'
/**
* @see \App\Http\Controllers\PostController::index
* @see app/Http/Controllers/PostController.php:11
* @route /posts
*/
export const index = (options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => ({
url: index.url(options),
method: 'get',
})
index.definition = {
methods: ['get',' head'],
url: '\/posts',
}
/**
* @see \App\Http\Controllers\PostController::index
* @see app/Http/Controllers/PostController.php:11
* @route /posts
*/
index.url = (options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => {
return index.definition.url + queryParams(options)
}
/**
* @see \App\Http\Controllers\PostController::index
* @see app/Http/Controllers/PostController.php:11
* @route /posts
*/
index.get = (options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => ({
url: index.url(options),
method: 'get',
})
/**
* @see \App\Http\Controllers\PostController::index
* @see app/Http/Controllers/PostController.php:11
* @route /posts
*/
index.head = (options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => ({
url: index.url(options),
method: 'head',
})
/**
* @see \App\Http\Controllers\PostController::show
* @see app/Http/Controllers/PostController.php:16
* @route /posts/{post}
*/
export const show = (args: { post: number | { id: number } } | [post: number | { id: number }] | number | { id: number }, options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => ({
url: show.url(args, options),
method: 'get',
})
show.definition = {
methods: ['get',' head'],
url: '\/posts\/{post}',
}
/**
* @see \App\Http\Controllers\PostController::show
* @see app/Http/Controllers/PostController.php:16
* @route /posts/{post}
*/
show.url = (args: { post: number | { id: number } } | [post: number | { id: number }] | number | { id: number }, options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => {
if (typeof args === 'string' || typeof args === 'number') {
args = { post: args }
}
if (typeof args === 'object' && !Array.isArray(args) && 'id' in args) {
args = { post: args.id }
}
if (Array.isArray(args)) {
args = {
post: args[0],
}
}
const parsedArgs = {
post: typeof args.post === 'object'
? args.post.id
: args.post,
}
return show.definition.url
.replace('{post}', parsedArgs.post.toString())
.replace(/\/+$/, '') + queryParams(options)
}
/**
* @see \App\Http\Controllers\PostController::show
* @see app/Http/Controllers/PostController.php:16
* @route /posts/{post}
*/
show.get = (args: { post: number | { id: number } } | [post: number | { id: number }] | number | { id: number }, options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => ({
url: show.url(args, options),
method: 'get',
})
/**
* @see \App\Http\Controllers\PostController::show
* @see app/Http/Controllers/PostController.php:16
* @route /posts/{post}
*/
show.head = (args: { post: number | { id: number } } | [post: number | { id: number }] | number | { id: number }, options?: { query?: QueryParams, mergeQuery?: QueryParams }, ) => ({
url: show.url(args, options),
method: 'head',
})
const PostController = { index, show }
export default PostController
Using Wayfinder in the Frontend
We can now refer to this PostController by importing it into any page. Let's open up our resources/js/pages/home.tsx page and add the following:
import { show } from "@/actions/App/Http/Controllers/PostController";
export default function Home() {
console.log(show(1));
return (
...
...
If you visit your home page now, you should see the following console output:
{
url: '/posts/1',
method: 'get'
}
This will specify how to get a post from our PostController's show method. For example, if we were to change the following route in our application:
Route::get('posts/{post}', [PostController::class, 'show'])->name('posts.show');
From posts/ to post/ for example
Route::get('post/{post}', [PostController::class, 'show'])->name('posts.show');
We don't have to do anything in our application except run the php artisan wayfinder:generate again. Wayfinder will automatically update the definitions for us.
Handling Forms with Wayfinder
If you are using a traditional form to submit data, Wayfinder can make your life a lot easier and allow you to destructure the action and method for your form, like the following:
<form {...store.form()} className="space-y-4">
Let's see how we can use this. First, we'll need to add a new route to our routes/web.php file:
// Post route to create new posts
Route::post('post', [PostController::class, 'store'])->name('posts.store');
We also need to create the store() method inside our PostController. Add the following to your PostController.php file:
public function store(Request $request) : JsonResponse
{
$post = Post::create($request->all());
return response()->json($post);
}
We'll keep it basic for this example, but you will undoubtedly want to verify the input for the new post. The next step is to renew our typescript definitions, but this time we want to make sure that form variants are included as well:
php artisan wayfinder:generate --with-form
Now, we can call store.form() to get the form action and method. Here is an example of the full home.tsx with the form:
import MainLayout from '@/layouts/main-layout';
import { Head, useForm, usePage } from '@inertiajs/react';
import { store } from "@/actions/App/Http/Controllers/PostController";
export default function Home() {
const props = usePage().props;
const { data, setData, processing, errors, reset } = useForm({ user_id: 1, title: '', body: '', slug: '' });
return (
<MainLayout>
<Head title= "Home"/>
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<h2 className="text-2xl font-bold mb-4">Create New Post</h2>
<form {...store.form()} className="space-y-4">
<input type="hidden" name="user_id" value={data.user_id} />
<input type="hidden" name="_token" value={props.csrf_token} />
<div className="space-y-2">
<input
placeholder= "Title"
name= "title"
value={data.title}
onChange={e => setData('title', e.target.value)}
className="w-full p-2 border rounded-md"
required
/>
{errors.title && <div className="text-red-500">{errors.title}</div>}
</div>
<div className="space-y-2">
<textarea
placeholder= "Body"
name= "body"
value={data.body}
onChange={e => setData('body', e.target.value)}
className="w-full p-2 border rounded-md h-32"
required
/>
{errors.body && <div className="text-red-500">{errors.body}</div>}
</div>
<div className="space-y-2">
<input
placeholder= "Slug"
name= "slug"
value={data.slug}
onChange={e => setData('slug', e.target.value)}
className="w-full p-2 border rounded-md"
required
/>
{errors.slug && <div className="text-red-500">{errors.slug}</div>}
</div>
<button
type= "submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
disabled={processing}
>
{processing ? 'Creating...': 'Create Post'}
</button>
</form>
</div>
</MainLayout>
);
}
Quick tip: We needed the
csrf_tokenfor our form, so you will need to add thecsrf_token()to the HandleInertiaRequests middleware to make it available viausePage().props
Now if you visit the / page, you'll see a simple Post form that allows you to create a new post.
Conclusion
Laravel Wayfinder revolutionizes the way developers bridge backend and frontend code in full-stack applications. By automating the generation of fully-typed TypeScript functions for controllers and routes, it minimizes errors, accelerates development, and guarantees consistency between server and client. Whether building APIs or complex forms, Wayfinder delivers a robust, type-safe solution for modern Laravel projects