DEV Community

Cover image for Laravel Wayfinder: Typed Routes and the Deploy Bug I Hit
Qasim Parray
Qasim Parray

Posted on Originally published at abrarqasim.com

Laravel Wayfinder: Typed Routes and the Deploy Bug I Hit

Three weeks ago a teammate renamed a route in a Laravel app we were shipping together, posts.show became posts.view because he hated the old name, and nothing in PHP complained. The break showed up on the frontend instead, quietly, because somebody (me, a few months earlier) had hardcoded /posts/${id} directly into a fetch call two files away from any route definition. Nobody noticed until a client clicked a broken link in production. That is the exact category of bug Laravel Wayfinder exists to make impossible, and after running it on a real Inertia project for a few weeks, it mostly delivers. Mostly, because it also handed me a new way to break a deploy that I had never hit before.

What Wayfinder actually generates

Wayfinder is a Laravel package that reads your registered routes and controllers, then generates fully typed TypeScript functions for each one. Install it with Composer and pair it with the official Vite plugin so it regenerates automatically as you work:

composer require laravel/wayfinder
npm i -D @laravel/vite-plugin-wayfinder
Enter fullscreen mode Exit fullscreen mode

Add the plugin to vite.config.js, and every time you touch a controller or a route file, a fresh set of TypeScript definitions lands in resources/js/actions and resources/js/routes. You can also run it by hand:

php artisan wayfinder:generate
Enter fullscreen mode Exit fullscreen mode

That command is doing something conceptually simple but tedious to write by hand: it walks the router, matches parameter bindings, resolves which controller method actually owns each route, and produces a function that knows its own URL and the shape of the arguments it expects. The HTTP method comes along for free too, which matters more than it sounds like once you're calling destroy() and show() from the same file.

Swapping hardcoded URLs for typed functions

Here's the pattern I was using before, which is probably familiar if you've built an Inertia or React frontend against a Laravel API:

// before: a string someone typed by hand, nowhere near the route definition
async function loadPost(id: number) {
  const response = await fetch(`/posts/${id}`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Nothing here checks that /posts/${id} still matches what's in routes/web.php. Rename the route, or swap the parameter binding from an id to a slug, and this line keeps compiling right up until it 404s in front of a user. With Wayfinder generating functions from the same PostController the route actually points to, the call becomes this instead:

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

async function loadPost(id: number) {
  const response = await fetch(show(id).url);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

If the route changes, wayfinder:generate regenerates show, and my code either keeps compiling because nothing meaningful changed, or TypeScript flags the call site because the parameters shifted. That second case is the entire point: a rename that used to fail silently in the browser now fails loudly at build time, on my machine, before anyone else sees it.

The part I didn't expect to like as much is how it handles routes that share a controller method. If two named routes point at the same action, Wayfinder can't guess which URL you mean, so it hands back a dictionary keyed by the route instead of a plain function:

import { index } from "@/actions/App/Http/Controllers/ClientPaymentsController";

// clients.payments.index and clients.payments.archive both hit ClientPaymentsController@index
index["/clients/{client}/payments"]({ client: 1 });
Enter fullscreen mode Exit fullscreen mode

The first time I saw that shape in an editor autocomplete, I assumed it was a bug in the generator. It isn't. It's Wayfinder refusing to silently guess which of two ambiguous routes you meant, which is a more honest failure mode than most route helpers I've used.

How this compares to what I was doing before

Before Wayfinder, my go-to for this problem was Ziggy, which exposes a route() helper on the frontend that mirrors Laravel's own named-route syntax. Ziggy solves a chunk of the same problem: you stop hardcoding URLs, and a route rename gets picked up automatically. What it doesn't give you is typed arguments. Calling route('posts.show', { id: 'not-a-number' }) compiles fine and fails at runtime, because Ziggy's helper takes a loosely typed object and doesn't know or care what the controller expects. Wayfinder's generated functions come from the same source of truth, the router, but because they're generated per-controller-method rather than interpreted at runtime, TypeScript actually understands the parameter shape. I didn't rip Ziggy out of the project; the two coexist fine, and there are still a few places where Ziggy's simpler route() call is genuinely less code for a one-off link. But every new fetch call I write goes through Wayfinder now, because the difference between "this compiles" and "this actually matches the controller" is exactly the gap that caused the bug I opened this post with.

The gotcha nobody mentions until you hit it

The bug I actually lost an afternoon to wasn't in the generated code at all. It was in the deploy pipeline. Wayfinder reads routes from the application's live router at generation time, and our deploy script ran php artisan optimize, which caches the route table, before the frontend build step. On the release where we added a new controller action, the cached route table from the previous release was still in memory when wayfinder:generate ran during npm run build. The new action was invisible to it. Vite's build failed with an error about a module that "didn't exist," pointing at a resources/js/actions file that had simply never been generated, because the router it asked didn't know the route existed yet.

The fix, once I found where to look, is one line in the deploy script, run before the frontend build and after the code is in place:

php artisan route:clear
npm run build
Enter fullscreen mode Exit fullscreen mode

Clearing the route cache before regenerating TypeScript definitions and only re-caching it afterward closed the gap. I want to be annoyed that this isn't the default behavior, but I also understand why it isn't: route caching and TypeScript generation are two features written by different people for different reasons, and the order they run in is a deploy-script decision, not a package decision. It just means you now own one more ordering constraint in a script that was already fragile.

Where it still falls short

Wayfinder only knows about routes that exist when you run the generator, so anything built dynamically at runtime, a route registered from a database-driven plugin system, for instance, won't show up no matter how carefully you order your deploy steps. I also had to opt in explicitly to generate the .form variants for plain HTML form submissions, which cost me twenty minutes of confusion before I found the flag:

php artisan wayfinder:generate --with-form
Enter fullscreen mode Exit fullscreen mode

And the package itself is still labeled beta, with the maintainers warning the API can change before a 1.0 release. That's a reasonable thing to be cautious about if you're gluing generated code into a large codebase you don't want to touch twice.

What I'd do this week

If you're already running Laravel with an Inertia or React frontend and you've ever hardcoded a URL string that later broke silently, install Wayfinder on a single low-risk feature branch. Wire in the Vite plugin, then replace the fetch calls on just one page and see how the generated types feel before you touch anything else. Then check your own deploy script for the same optimize before build ordering that bit me. It takes about ten minutes to confirm, and it's a lot cheaper to fix before a release than after one. I write up more of this kind of Laravel tooling friction, alongside the Pint formatting change that broke a client's git blame, on this blog, and I take on exactly this flavor of frontend-backend integration work through my consulting practice when teams want a second set of eyes before they ship it.


Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.

Top comments (0)