Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Wayfinder Laravel Package

laravel/wayfinder

Laravel Wayfinder bridges your Laravel backend and TypeScript frontend by generating fully-typed, importable TypeScript functions for your controllers and routes—no more hardcoded URLs or manual syncing. Includes a Vite plugin that regenerates definitions automatically during builds and dev server r...

View on GitHub
Deep Wiki
Context7

Getting Started

Wayfinder is a build-time code generator, not a runtime HTTP client. It introspects your Laravel router and controller signatures and emits typed TypeScript modules that resolve a route to { url, method }. The payoff is eliminating hand-maintained URL strings in your frontend.

Minimal setup:

composer require laravel/wayfinder
npm i -D @laravel/vite-plugin-wayfinder
// vite.config.ts
import { wayfinder } from "@laravel/vite-plugin-wayfinder";

export default defineConfig({
    plugins: [wayfinder()],
});

Then either let the Vite plugin generate on dev-server changes and builds, or run it manually:

php artisan wayfinder:generate

Where to look first, in order:

  1. The generated output. Open resources/js/actions/App/Http/Controllers/PostController.ts. The generated file is readable, plain TypeScript — it is the authoritative answer to "what arguments does this function take?" Any time the types surprise you, read the file rather than guessing.
  2. resources/js/routes/ — one module per named route group, mirroring your route names. Usually the nicer import surface.
  3. resources/js/wayfinder/ — shared runtime helpers (argument normalization, query-string building, form variants). You rarely import from here directly, but this is where query/merge behavior actually lives.
  4. php artisan route:list — if a route is missing from the generated output, this is your ground truth for what the generator saw.

First use case: replace a hardcoded Inertia link.

// before
<Link href={`/posts/${post.id}`}>View</Link>

// after
import { show } from "@/routes/post";
<Link href={show(post.id)}>View</Link>

Adopt it incrementally — one form or nav component at a time. Since the generated directories are rebuilt wholesale, there is no migration burden: you can convert call sites opportunistically without touching the backend.

Two early hygiene steps: add wayfinder/actions/routes to .gitignore, and run npx tsc --noEmit once right after the first generation. If your controller parameter types don't map cleanly to TypeScript, you want to find that out on day one, not after you have converted twenty components.

Implementation Patterns

Choose between action imports and route imports deliberately. Route imports (@/routes/post) are keyed by route name and survive controller refactors; action imports (@/actions/.../PostController) are keyed by class and survive route renames. For day-to-day work, route imports are the better default because route names are already the stable contract your backend team treats as public API.

Two levels of granularity per action. Named imports give you the callable plus method variants; importing the controller object gives you PostController.show(1) but defeats tree-shaking, pulling every action in that controller into the bundle. Prefer import { show } from ... in application code.

Pick your accessor per call site. The callable returns the full descriptor, which is what Inertia wants; .url() returns a bare string, which is what fetch and Link href want. Use the narrowest form that satisfies the consumer so intent is obvious:

show(1);              // { url: "/posts/1", method: "get" }
show.url(1);          // "/posts/1"
show.head(1);         // { url: "/posts/1", method: "head" }

Argument shapes are flexible — standardize anyway. Every action accepts a scalar, a positional array, a flat keyed object, or a nested object:

update([1, 2]);
update({ post: 1, author: 2 });
update({ post: { id: 1 }, author: { id: 2 } });

All four are valid, but mixing them across a codebase makes review harder. Pick the keyed-object form for multi-parameter actions (it's self-documenting and refactor-safe against parameter reordering) and the scalar form for single-parameter actions. For custom bindings (/posts/{post:slug}), the key becomes the binding field, so show("my-new-post") and show({ slug: "my-new-post" }) both work — a good reason to be explicit with { slug: ... } when the route uses a non-id key.

Forms are a first-class workflow. Generate with --with-form, then spread the descriptor onto a native <form>:

<form {...update.form(1)}>
{/* <form action="/posts/1?_method=PATCH" method="post"> */}

This is the cleanest way to handle method spoofing — you never write _method by hand again, and switching a route from PATCH to PUT propagates automatically. Use .form.put(), .form.patch() etc. when the route registers multiple verbs.

Inertia integration is the high-traffic path. useForm().submit() accepts the Wayfinder descriptor directly, so form submissions become:

form.submit(store()); // POST /posts, no manual method string

This removes the single most common class of frontend/backend drift: a form that posts to a URL that has since changed verb or path.

Query parameters belong in the options argument. Build filters and pagination with query for a fixed set, mergeQuery when you want to preserve the current URL state:

show.url(1, { query: { page: 1, sort_by: "name" } });
show.url(1, { mergeQuery: { page: 2, sort_by: null } }); // drops sort_by

The mergeQuery + null combination is the idiomatic way to build filter UIs: merge the changed keys into window.location.search, set removed filters to null, and pass the result to Inertia's router. Note this reads window.location, so it's browser-context code — don't reach for it in a module that might be imported during SSR.

Treat generation as part of the type-check pipeline. In CI, order matters: php artisan wayfinder:generate must run before tsc/vite build, or you type-check stale output. If you use the Vite plugin, the build handles this — but a standalone npm run typecheck script does not, so add generation to that script or to a pretest hook. A composer script that runs wayfinder:generate after route:clear keeps backend and frontend in lockstep for local devs who don't run Vite.

Relocate output only if you must. --path moves the base directory, and all three subdirectories move under it. If your frontend lives outside resources/js or you want generated code isolated from hand-written code, set it once in the Vite plugin config rather than passing the flag at every call site.

Gotchas and Tips

Route caching is the number-one failure mode. Wayfinder generates from the registered router. If the app boots against a cached route table from a previous deploy, routes added since the last route:cache are silently absent — no error, just missing files, and Vite then fails with something like Could not load resources/js/routes/<name>. If your deploy ends with php artisan optimize or route:cache, you must run route:clear before regenerating, and before npm run build when the Vite plugin triggers generation:

php artisan route:clear
npm run build

The confusing part is that the error surfaces in Vite as a missing module, which sends people hunting through tsconfig paths. When a generated module "doesn't exist," check the route cache first.

Duplicate URIs turn your callable into a dictionary. Two routes pointing at the same controller method means Wayfinder can't disambiguate, so the export becomes keyed by URI:

index["/clients/{client}/payments"]({ client: 1 });

This is a silent, breaking shape change — the function compiles fine as an object, and calls fail at the call site rather than at generation. It also happens later, when a teammate adds a second route to an existing action and breaks a working import. Mitigation: prefer named-route imports for anything non-trivial, and keep an eye on actions with multiple registered routes (aliases, legacy redirects, archive endpoints).

Reserved words get suffixed. A controller method named delete or import exports as deleteMethod / importMethod, since those aren't legal JS identifiers. If your import fails with "no exported member," check the generated file rather than assuming the method name carried through. Recent releases also fixed reserved keywords appearing as parameter names in array syntax — worth being on a current patch if you use array-form arguments.

Beta status is a real constraint. The API is explicitly subject to change before 1.0, and there's a separate next branch. Pin the Composer package and the Vite plugin together and upgrade them in lockstep; a plugin emitting one output shape against a package expecting another produces confusing type errors. For a production app, treat upgrades as a deliberate task with a tsc --noEmit gate, not something a floating range should handle.

Generated type fidelity depends on what the generator can infer. Model docblocks are parsed as a fallback, and the number/string mapping is database-aware (Postgres int4 maps to number). UUID primary keys, custom casts, and Route:: closures without controller signatures can all widen to unions or fall back to string | number. This is usually fine — but if a component needs a strict type, don't fight the generated one; narrow at the call site and let the generated type stay permissive.

Dynamic URL defaults resolve at call time. URL defaults configured via middleware are evaluated at runtime rather than baked into the generated URL. Correct behavior in the browser, but it means a call made before defaults are established (early boot, some SSR or test contexts) can produce a URL missing those segments. If URLs look incomplete in tests, check whether the default-setting middleware has run.

Don't confuse .form() with the HTTP descriptor. .form() returns { action, method } for native HTML forms; the plain callable returns { url, method } for Inertia. Passing a .form() result to form.submit() won't do what you want. Two shapes, two consumers — pick by whether the consumer is a DOM form or an Inertia call.

Wayfinder does not fetch. It hands you a URL and a method. You still choose fetch, axios, or router.visit. This is deliberate and keeps it framework-agnostic, but it means there's no request/response typing — don't expect it to replace something like a generated API client.

Debugging is straightforward if you use the generated source. The output is human-readable TypeScript. Reading the relevant generated file answers nearly every "why doesn't this typecheck" question faster than reasoning about middleware, bindings, and controller signatures. Keep the directories gitignored so nobody hand-edits them — edits vanish on the next build, and the resulting bug (works locally, gone after rebuild) is genuinely unpleasant to track down.

Extension points are thin, by design. Beyond the CLI flags (--path, --skip-actions, --skip-routes, --with-form) and the Vite plugin's config, there's no documented hook system. The practical extension pattern is a thin hand-written layer over the generated modules — a routes.ts barrel that re-exports the ones you use, or a wrapper that attaches a shared client — so you have one place to adapt if the beta API shifts under you.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
bernskioldmedia/laravel-matomo
bernskioldmedia/laravel-harvest
bernskiold/laravel-data-scrubber
bernskiold/laravel-currency-converter
bernskiold/laravel-campaign-monitor
bernskioldmedia/laravel-autotranslate
siberfx/telegram-bot
roy-sandip/laravel-view-extended
gregsphotography/site-builder-core
roy-sandip/laravel-view
anodyne/blade-fluentui-icons
webx-ui/module-catalog-collections
webx-ui/module-catalog-feeds
makidizajnerica/laravel-dto
matviib/scheduler
webx-ui/templates
anodyne/blade-tabler-icons
bref/laravel-websockets
anodyne/blade-hugeicons
1994/flatpack-statamic