DEV Community

Cover image for Laravel Wayfinder: Type Safe Routes and Forms with Inertia
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Laravel Wayfinder: Type Safe Routes and Forms with Inertia

Originally published at hafiz.dev


If you've built a full stack Laravel app with Inertia, you know the pain. You rename a route, forget to update your React component, and spend twenty minutes wondering why your form submission is hitting a 404. Or you add a field to your form request validation, but your frontend is still sending the old payload structure.

Laravel Wayfinder fixes this. It reads your PHP code and generates TypeScript that stays in sync with your backend automatically. Routes, form request types, model types, enums everything your frontend needs to know about your backend gets generated and typed.

Here's the thing that got my attention: as of early January 2026, Wayfinder shipped a major new beta that goes way beyond route generation. The previous version (which replaced Ziggy in Laravel Starter Kits) only handled routes. This new version generates TypeScript from form requests, Eloquent models, PHP enums, Inertia props, broadcast channels, and more.

Let me show you how to set it up and why the form request integration alone makes this worth using.

Wayfinder vs Ziggy: What's Different?

Before Wayfinder, most of us used Ziggy for route generation. Ziggy did one thing well: it let you call route('posts.show', { post: 1 }) in your JavaScript and get back the URL. That's it.

Wayfinder takes a completely different approach. Instead of just mapping route names to URLs, it analyzes your entire Laravel codebase and generates TypeScript that mirrors your PHP structures.

Feature Ziggy Wayfinder
Route URLs
HTTP method awareness
Form request types
Eloquent model types
PHP enum constants
Inertia page props
Broadcast channels
Environment variables

The route generation alone is more powerful than Ziggy. Wayfinder generates actual TypeScript functions for each controller method, complete with typed parameters. But the real value is everything else particularly form request types.

As of Laravel 12, Wayfinder replaced Ziggy in the official Starter Kits. So if you're starting a new Laravel project with Inertia, you're already set up with the stable version. This tutorial covers the new beta with expanded features.

Installation and Setup

First, install the beta version via Composer:

composer require laravel/wayfinder:dev-next
Enter fullscreen mode Exit fullscreen mode

Publish the configuration file to customize what gets generated:

php artisan vendor:publish --tag=wayfinder-config
Enter fullscreen mode Exit fullscreen mode

Next, add the Vite plugin to vite.config.js. This handles automatic regeneration when your PHP files change:

import { wayfinder } from "@laravel/vite-plugin-wayfinder";

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/js/app.tsx'],
            refresh: true,
        }),
        wayfinder(),
        react(),
    ],
});
Enter fullscreen mode Exit fullscreen mode

Generate the TypeScript files:

php artisan wayfinder:generate
Enter fullscreen mode Exit fullscreen mode

By default, Wayfinder outputs everything to resources/js/wayfinder. If you want a different location:

php artisan wayfinder:generate --path=resources/ts/api
Enter fullscreen mode Exit fullscreen mode

Here's what most tutorials won't mention: once you have the Vite plugin configured, you don't need to manually run the generate command during development. When Vite's dev server is running, Wayfinder watches for changes to your PHP files and regenerates automatically. Change a route, update a form request—your TypeScript updates within seconds.

You can safely add the wayfinder directory to your .gitignore since it regenerates on every build.

Route Generation Basics

Let's start with a typical controller:

class PostController
{
    public function index() { /* ... */ }
    public function show(Post $post) { /* ... */ }
    public function store(StorePostRequest $request) { /* ... */ }
    public function update(UpdatePostRequest $request, Post $post) { /* ... */ }
    public function destroy(Post $post) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Wayfinder generates a TypeScript module you can import and call like any function:

import { PostController } from "@/wayfinder/App/Http/Controllers/PostController";

// Returns both URL and HTTP method
PostController.index();
// { url: '/posts', method: 'get' }

// Routes with parameters are typed
PostController.show({ post: 1 });
// { url: '/posts/1', method: 'get' }

PostController.update({ post: 42 });
// { url: '/posts/42', method: 'put' }
Enter fullscreen mode Exit fullscreen mode

Need just the URL string? Each function has a .url() method:

PostController.show.url({ post: 42 });
// '/posts/42'
Enter fullscreen mode Exit fullscreen mode

Query parameters work too:

PostController.index({ query: { page: 2, sort: "created_at" } });
// { url: '/posts?page=2&sort=created_at', method: 'get' }
Enter fullscreen mode Exit fullscreen mode

If you prefer named routes (like Laravel's route() helper), Wayfinder generates those as well:

import posts from "@/wayfinder/routes/posts";

posts.index();
posts.show({ post: 1 });
Enter fullscreen mode Exit fullscreen mode

The TypeScript compiler catches parameter errors at build time. Try calling PostController.show() without the post parameter and you'll see an error before your code ever runs.

Form Handling with Inertia

This is where Wayfinder becomes genuinely useful for real applications.

HTML forms only support GET and POST methods. When you need PUT, PATCH, or DELETE, Laravel uses method spoofing a hidden _method field that tells the framework what you actually want. Wayfinder handles this automatically with the .form variant:

PostController.update.form({ post: 1 });
// { action: '/posts/1?_method=PATCH', method: 'post' }

PostController.destroy.form({ post: 1 });
// { action: '/posts/1?_method=DELETE', method: 'post' }
Enter fullscreen mode Exit fullscreen mode

You can spread these directly onto your form element in React:

<form {...PostController.update.form({ post: post.id })}>
    {/* fields */}
</form>
Enter fullscreen mode Exit fullscreen mode

But here's where it gets really good: form request validation rules become TypeScript types.

Given this Laravel form request:

class StorePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
            'excerpt' => ['nullable', 'string'],
            'tags' => ['nullable', 'array'],
            'tags.*' => ['string'],
            'meta' => ['nullable', 'array'],
            'meta.description' => ['nullable', 'string'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Wayfinder generates this in types.d.ts:

export namespace App.Http.Controllers.PostController.Store {
    export type Request = {
        title: string;
        body: string;
        excerpt?: string | null;
        tags?: string[] | null;
        meta?: {
            description?: string | null;
        } | null;
    };
}
Enter fullscreen mode Exit fullscreen mode

Notice how it handles nullable fields (marked as optional with ?), arrays, and even nested objects. The tags.* rule becomes string[]. The meta.description rule becomes a nested object type.

Now use it with Inertia's useForm:

import { App } from "@/wayfinder/types";
import { useForm } from '@inertiajs/react';

const form = useForm<App.Http.Controllers.PostController.Store.Request>({
    title: '',
    body: '',
    excerpt: null,
    tags: [],
    meta: { description: null },
});
Enter fullscreen mode Exit fullscreen mode

TypeScript now knows exactly what fields are valid and their types. Add a field in your form request validation, regenerate Wayfinder, and TypeScript will error if you haven't updated your frontend form.

Here's a complete component example:

import { App } from "@/wayfinder/types";
import { PostController } from "@/wayfinder/App/Http/Controllers/PostController";
import { useForm } from '@inertiajs/react';

export function CreatePost() {
    const form = useForm<App.Http.Controllers.PostController.Store.Request>({
        title: '',
        body: '',
        excerpt: null,
        tags: [],
    });

    const submit = (e: React.FormEvent) => {
        e.preventDefault();
        form.post(PostController.store.url());
    };

    return (
        <form onSubmit={submit}>
            <input
                name="title"
                value={form.data.title}
                onChange={e => form.setData('title', e.target.value)}
            />
            {form.errors.title && <span>{form.errors.title}</span>}

            <textarea
                name="body"
                value={form.data.body}
                onChange={e => form.setData('body', e.target.value)}
            />
            {/* TypeScript error if you use wrong field name */}

            <button type="submit" disabled={form.processing}>
                Create Post
            </button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

The key insight: your validation rules are defined once in PHP. TypeScript types are generated automatically. No more manual type definitions that drift from your backend. When you add a new validation rule or change an existing one, your frontend types update on the next build.

Model Types

Wayfinder analyzes your Eloquent models and generates TypeScript interfaces for them. Given:

class Post extends Model
{
    protected $casts = [
        'published_at' => 'datetime',
        'is_featured' => 'boolean',
    ];

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Wayfinder generates:

export namespace App.Models {
    export type Post = {
        id: number;
        title: string;
        body: string;
        published_at: string | null;
        is_featured: boolean;
        author: App.Models.User;
        tags: App.Models.Tag[];
        created_at: string;
        updated_at: string;
    };
}
Enter fullscreen mode Exit fullscreen mode

The casts matter. datetime becomes string | null (since it serializes to ISO format). boolean stays boolean. Relationships are typed based on their return type.

Use these in your components:

import { App } from "@/wayfinder/types";

interface PostCardProps {
    post: App.Models.Post;
}

export function PostCard({ post }: PostCardProps) {
    return (
        <article>
            <h2>{post.title}</h2>
            <p>By {post.author.name}</p>
            {post.is_featured && <span className="badge">Featured</span>}
        </article>
    );
}
Enter fullscreen mode Exit fullscreen mode

Full autocomplete in your IDE. Type errors if you access a property that doesn't exist.

PHP Enums

PHP 8.1 enums translate directly to TypeScript. Given:

enum PostStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Archived = 'archived';
}
Enter fullscreen mode Exit fullscreen mode

Wayfinder generates both a type (for type checking) and constants (for runtime use):

// In types.d.ts
export namespace App.Enums {
    export type PostStatus = "draft" | "published" | "archived";
}

// In App/Enums/PostStatus.ts
export const Draft = "draft";
export const Published = "published";
export const Archived = "archived";

export const PostStatus = { Draft, Published, Archived } as const;

export default PostStatus;
Enter fullscreen mode Exit fullscreen mode

Use the constants in your code:

import PostStatus from "@/wayfinder/App/Enums/PostStatus";
import { App } from "@/wayfinder/types";

interface Props {
    post: App.Models.Post & { status: App.Enums.PostStatus };
}

export function PostStatusBadge({ post }: Props) {
    if (post.status === PostStatus.Published) {
        return <span className="badge-green">Live</span>;
    }

    if (post.status === PostStatus.Draft) {
        return <span className="badge-yellow">Draft</span>;
    }

    return <span className="badge-gray">Archived</span>;
}
Enter fullscreen mode Exit fullscreen mode

Add a new enum case in PHP, regenerate, and TypeScript will tell you everywhere you need to handle it.

Should You Use It Now?

Let me be direct about the current state. The new Wayfinder is what Joe Tannenbaum called an "uppercase B beta." The API may change before v1.0. Performance on very large codebases still needs optimization. Some edge cases with complex type inference aren't fully covered yet.

That said, here's my take:

For new Inertia projects: Yes, use it. The form request types alone will save you hours of debugging. The risk of breaking changes is outweighed by the productivity gains, and refactoring to accommodate API changes is straightforward.

For existing projects: Evaluate your situation. If you have a medium sized codebase and you're already dealing with frontend/backend sync issues, it's worth trying. If you're risk-averse or have a massive monolith, maybe wait for stable.

What I'd skip for now: If you're interested in the broadcast channel/event types or multi-repo syncing (which is genuinely impressive), those features work but are more complex to set up. Focus on routes and form requests first.

The team is actively collecting feedback on GitHub. If you run into issues, report them they're responsive and the package is improving rapidly.

What's Not Covered Here

This post focused on the features I think will matter most for everyday Inertia development. Wayfinder does more that I didn't cover:

  • Broadcast channels and events for Laravel Echo WebSocket integration with typed payloads
  • Cross-repository syncing via GitHub Actions to keep separate frontend/backend repos in sync
  • Inertia shared props typing from your HandleInertiaRequests middleware
  • Vite environment variable types for import.meta.env

I'll cover some of these in future posts once the API stabilizes.

Resources

FAQ

What is Laravel Wayfinder?

Laravel Wayfinder is an official Laravel package that generates TypeScript from your PHP code. It analyzes your controllers, routes, form requests, models, and enums to create fully-typed TypeScript that stays in sync with your backend automatically.

What's the difference between Wayfinder and Ziggy?

Ziggy only generates route URLs from route names. Wayfinder generates route URLs plus HTTP methods, form request types, model types, PHP enum constants, Inertia page props, broadcast channels, and more. Wayfinder has replaced Ziggy in Laravel's official Starter Kits as of Laravel 12.

Does Wayfinder work with Vue and React?

Yes. Wayfinder generates framework-agnostic TypeScript that works with any frontend. It has specific integrations for Inertia.js (both Vue and React adapters) including typed page props and shared data. If you're using Laravel Echo with Vue or React, it can also generate typed event handlers.

Is Wayfinder ready for production?

The new version is in public beta. The previous version (route generation only) is stable and ships with Laravel Starter Kits. For the beta features (form requests, models, enums), the API may change before v1.0, but it's functional and actively maintained. I'd use it for new projects; for existing production apps, evaluate based on your risk tolerance.

Top comments (0)