Routing
The defineRoute function is the runtime companion to the Laravel TypeScript Publisher's route generation. For every controller with at least one publishable route, the Laravel package writes a single TypeScript file exporting one defineRoute() call per action — similar in spirit to Laravel Wayfinder, but far more compact: all the URL-building, parameter-binding, query-string, and form-spoofing logic lives inside defineRoute itself instead of being generated inline for every single route.
As with enums, this package is not meant to be used standalone — install it alongside the Laravel package as described in Installation & Usage.
How Routes Are Generated
One
.tsfile is generated per controller, at a modular, namespace-derived path (e.g.App\Http\Controllers\PostController→app/http/controllers/post-controller.ts).Each file exports one named
constper controller action — named after the controller method, not the Laravel route name — plus adefaultexport containing all of that controller's actions as a single object.Barrel
index.tsfiles are written per namespace directory, but — unlike models, enums, and resources — route barrels only re-export each controller's default export, never named exports:typescript// app/http/controllers/index.ts export { default as PostController } from "./post-controller"; export { default as UserController } from "./user-controller";This avoids collisions between controllers that share method names (
index,show,store, ...).Invokable controllers (a single
__invokemethod) export their one action directly as the default export, so you call the controller itself:typescriptimport NamedInvokableController from "@js/types/data/app/http/controllers/named-invokable-controller"; NamedInvokableController(); // { url: '/named-invokable', method: 'get' }If an invokable controller has additional public actions besides
__invoke, those are attached to the default export viaObject.assignso you can still call them as properties (InvokableModelBoundPlusController.extra(...)).Multiple Laravel routes that map to the same controller method are de-duplicated into a single export — if one of them is named, the named route wins.
HEADis always omitted frommethods(Laravel adds it implicitly to everyGETroute).A method decorated with
#[TsExclude](or a controller class decorated with it) is skipped entirely — see Filtering & Excluding Routes.
Anatomy of defineRoute
Every generated action looks like this:
export const show = defineRoute({
name: "posts.show", // Laravel route name, or omitted if unnamed
url: "/posts/{post}", // URI template (or `{domain}{uri}` for domain routes)
domain: "api.example.com", // only present for domain-restricted routes
methods: ["get"] as const,
args: [{ name: "post", required: true, _routeKey: "id" }] as const,
component: "PostShow", // only present for Inertia routes, see below
});The value returned by defineRoute() is a callable object with:
| Member | Description |
|---|---|
route(...) | Calling it directly returns { url, method }, using the route's primary (first declared) HTTP method. |
route.url(...) | Same calling conventions as above, returns just the URL string. |
route.get(...), route.post(...), … | One method per declared HTTP verb, returning { url, method } for that specific verb. |
route.form(...) | Builds { action, method } for an HTML <form> — see Building Forms. |
route.form.put(...), route.form.delete(...), … | Per-verb form variants — these add Laravel's _method spoofing automatically. |
route.definition | The raw metadata object passed to defineRoute(). |
route.toString() | Returns the URL with no parameters substituted — lets you drop a route directly into a template literal. |
${route(...)} | Using a route directly in a template literal calls the route and inserts the URL string. |
Calling a Route
All the conventions below are equivalent — pick whichever reads best at the call site:
// Named object
PostController.show({ post: 42 });
// Positional spread
PostController.show(42);
// Positional array
PostController.show([42]);
// A model instance / any object with an `.id` or the bound route key
PostController.show(post); // post = { id: 42, title: '...' }For routes with multiple parameters, the same conventions apply positionally or as named keys:
UserPostController.show({ user: 2, post: 42 });
UserPostController.show(2, 42);
UserPostController.show([2, 42]);
UserPostController.show(someUser, somePost);Naked model/object shortcut
Passing a bare object (PostController.show(post)) only works for routes with exactly one declared parameter, and only when the object has an id property, a _routeKey property, or the property named by the route's binding key (e.g. slug).
Trailing query options
Any of the calling conventions above accept a trailing options object for query parameters (see Query Parameters):
PostController.show(42, { query: { preview: true } });
PostController.show({ post: 42 }, { query: { preview: true } });A trailing argument is only treated as "options" (rather than a route parameter) when you pass more arguments than the route declares and the last one is a plain object that doesn't contain any of the route's parameter names.
Model Binding
When a route parameter is type-hinted to an Eloquent model, the generated args entry includes a _routeKey field naming the column used to resolve it (mirroring Laravel's own route model binding):
// Route::get('/slug-posts/{slugPost:slug}', ...)
args: [{ name: 'slugPost', required: true, _routeKey: 'slug' }] as const,At the call site, you can pass the raw key value or the object itself — defineRoute extracts value[_routeKey], falling back to value.id:
CustomRouteKeyController.show({ slugPost: "hello-world" });
CustomRouteKeyController.show(post); // post = { slug: 'hello-world', ... }Because the binding is resolved structurally (via the _routeKey string), the generated route file never imports the PHP model's TypeScript type — you get full type inference (string | number | { slug: string | number }) without a single model import.
Enum Binding
Route parameters type-hinted to a backed enum resolve the same way, using the enum's backing values instead of a route key:
args: [{ name: 'status', required: true, _enumValues: [0, 1] }] as const,EnumBoundController.byStatus({ status: 0 }); // raw backing value
EnumBoundController.byStatus({ status: Status.Active }); // enum case value
EnumBoundController.byStatus({ status: Status.from(0) }); // a defineEnum() instanceJust like model binding, no enum import is required — TypeScript infers the union of valid backing values directly from _enumValues.
Optional Parameters & where Constraints
Parameters from {param?} segments are marked required: false and become optional in both the named-object and positional forms:
export const show = defineRoute({
url: "/optional/{param?}",
methods: ["get"] as const,
args: [{ name: "param", required: false }] as const,
});
OptionalParamController.show(); // '/optional'
OptionalParamController.show({ param: "x" }); // '/optional/x'Parameters constrained with ->where(...) in Laravel include a where regex, validated at runtime — an invalid value throws:
args: ([{ name: "id", required: true, where: "[0-9]+" }] as const,
TypedParamController.showInt({ id: "abc" }));
// throws: Route error: 'id' parameter 'abc' does not match required format '[0-9]+'.Domain Routes
Domain-restricted routes include a domain field, and their compiled URL is protocol-relative (so it works as-is with fetch() or an <a href>, resolving against whichever protocol the current page uses):
export const index = defineRoute({
url: "api.example.com/domain",
domain: "api.example.com",
methods: ["get"] as const,
});
DomainController.index(); // '//api.example.com/domain'Query Parameters
Any argument key that doesn't match a declared route parameter becomes a query string parameter:
PostController.index({ q: "search", page: 2 }); // '/posts?q=search&page=2'Booleans are encoded as
0/1(matching how Laravel parses query input).Arrays use indexed notation:
tags[0]=a&tags[1]=b._queryescape hatch — use this when a query key would otherwise collide with a route parameter name:typescriptPostController.index({ sort: "created_at", _query: { sort: "desc" } }); // '/posts/sort/created_at?sort=desc'mergeQuery— merges (and can delete, vianull/undefinedvalues) keys into the current page's query string, useful for updating one filter without losing the others:typescript// current URL: /posts?sort=name&page=3 PostController.index({}, { mergeQuery: { page: 1 } }); // '/posts?sort=name&page=1'
Route Defaults
Mirrors Laravel's URL::defaults() — set values once and they're substituted automatically wherever a matching parameter name is required but not supplied:
import { setRouteDefaults, addRouteDefault } from "@tolki/ts";
setRouteDefaults({ locale: "en" });
addRouteDefault("locale", "fr"); // overwrite a single keygetRouteDefaults() reads the current defaults, and resetRouteDefaults() clears them (handy in test setup/teardown).
Building Forms
.form(...) builds { action, method, toString() } for classic HTML <form> submissions, where method is always 'get' or 'post' (the only two methods HTML forms support). This matches Laravel Wayfinder's behavior exactly: the bare call already spoofs the route's primary method for you — there's no need to reach for a per-verb variant unless you want to submit as a different verb than the route's first declared method.
const { action, method } = PostController.store.form();
// action: '/posts', method: 'post'
PostController.update.form({ post: 42 });
// { action: '/posts/42?_method=PUT', method: 'post' } — primary method (PUT) spoofed automatically
PostController.destroy.form({ post: 42 });
// { action: '/posts/42?_method=DELETE', method: 'post' } — primary method (DELETE) spoofed automaticallyPer-verb form methods (.form.put(...), .form.patch(...), .form.delete(...), .form.get(...)) are only needed when a route supports multiple verbs and you want to submit as one that isn't the primary one:
// A route registered for both PUT and PATCH — primary is 'put'
PostController.update.form(); // spoofs _method=PUT (primary)
PostController.update.form.patch({ post: 42 }); // explicitly spoofs _method=PATCH insteadGET/POST routes never get a spoof added, since HTML forms natively support both.
Inertia Integration
When inertia.enabled is on in the Laravel package's config, each action that renders an Inertia response automatically gets a component field and a page-props type — you never call the annotation helpers yourself, they're already applied in the generated file:
export type PostPageProps = Inertia.SharedData & { post: Post };
export const post = annotatePageProps<PostPageProps>()(
defineRoute({
name: "inertia.post",
url: "/inertia/post/{post}",
methods: ["get"] as const,
args: [{ name: "post", required: true, _routeKey: "id" }] as const,
component: "PostShow",
}),
);Use InferPageProps<typeof route> on the frontend to read the page-props type back out — handy for typing a page component's props without a separate import:
import type { InferPageProps } from "@tolki/ts";
import { post } from "@js/types/data/app/http/controllers/inertia-controller";
type Props = InferPageProps<typeof post>; // PostPagePropsConditional (multi-component) routes
An action that conditionally renders different Inertia components (e.g. based on auth state) generates a component object instead of a string, plus a union page-props type:
export type ConditionalAuthenticatedPageProps = Inertia.SharedData & {
user: unknown;
};
export type ConditionalGuestPageProps = Inertia.SharedData & {
message: string;
};
export const conditional = annotatePageProps<
ConditionalAuthenticatedPageProps | ConditionalGuestPageProps
>()(
defineRoute({
url: "/inertia/conditional",
methods: ["get"] as const,
component: {
authenticated: "Conditional/Authenticated",
guest: "Conditional/Guest",
} as const,
}),
);route.component gives you the whole map, and route.withComponent(componentValue, ...args) tags a call result with a specific variant (it accepts one of the values from the map, e.g. 'Conditional/Authenticated', not the key) — useful for logging or for selecting which frontend component to render based on which variant a given call represents.
Inertia UI Table Props
Routes that render an Inertia UI Table get a typed page prop without the package ever evaluating the table object's toArray() method — the prop type is inferred from the table's statically-declared resource model instead.
use App\Tables\MerchandiseTable;
use Inertia\Inertia;
public function index()
{
return Inertia::render('Merchandise/MerchandiseIndex', [
'merchandise' => MerchandiseTable::make()->defaultSort('-id'),
]);
}When the table declares a static resource model, the generated page prop uses TableResource<TModel>, imported directly from the Inertia UI Table package you have installed. The package is auto-detected from your package.json (@inertiaui/table-vue or @inertiaui/table-react):
import type { TableResource } from "@inertiaui/table-vue";
import type { Merchandise } from "../models";
export type IndexPageProps = Inertia.SharedData & {
merchandise: TableResource<Merchandise>;
};If you use the React table package, the import is generated as @inertiaui/table-react instead. To force a specific package (or use a custom alias), set inertia.ui_table_package in config/ts-publish.php.
Supported model inference (all read statically, never instantiating the table):
protected ?string $resource = Merchandise::class;— the model is read from the property's default value.- A
query(): Buildermethod returningMerchandise::query(),Merchandise::class, or a query chain rooted at the model class. - Service-layer props where the controller passes
$this->resource->index($request)and the service method returns an array containing table props.
Dynamic/stateful tables whose model only exists in runtime constructor state are not statically inferable; use #[TsCasts] on the controller method for fully custom prop typing.
Table-Tainted Controllers
When a controller (or the resource it delegates to) renders an Inertia UI Table in any of its actions — even a sibling action unrelated to the route being published — the entire controller is considered "table-tainted." To avoid autoloading optional export dependencies such as Excel/PhpSpreadsheet during ts:publish, deep static analysis is skipped for every action on that controller.
Affected routes still get their route helpers, but they receive no auto-generated page-prop type — the route appears as a helper function without a corresponding PageProps type export.
Why This Happens (It Is Not the Inertia UI Table Package)
This workaround exists because of a bug in phpoffice/phpspreadsheet — not the Inertia UI Table package, and not your code. The dependency chain is:
The Inertia UI Table package does not require any spreadsheet code. It only optionally integrates with
maatwebsite/excel— asuggest/dev dependency ("To export tables to CSV, Excel, etc.") — so tables can export.If your app installs
maatwebsite/excelto enable those exports, that package requiresphpoffice/phpspreadsheet.phpoffice/phpspreadsheetships aSimpleCache1cache shim whoseget()signature is incompatible with the typedPsr\SimpleCache\CacheInterface::get()it implements under newerpsr/simple-cache(v2/v3). The moment PHP loads that class it raises an uncatchable compile-time (E_COMPILE_ERROR) fatal:textDeclaration of PhpOffice\PhpSpreadsheet\Collection\Memory\SimpleCache1::get($key, $default = null) must be compatible with Psr\SimpleCache\CacheInterface::get(string $key, mixed $default = null): mixed
A table's toArray() builds its exports definition, which reaches that Excel/PhpSpreadsheet code path. So when ts:publish statically evaluates a table to type a route, it triggers the class load and the fatal — even on a sibling route that has nothing to do with exports. The taint skip simply prevents ts:publish from evaluating tables at all, sidestepping the broken transitive dependency. Once phpoffice/phpspreadsheet is fixed for your PHP / psr/simple-cache version (or the optional Excel integration is not installed), none of this is necessary.
Opting Back In with #[TsCasts]
To get precise page-prop types for a specific method on a table-tainted controller, annotate it with #[TsCasts([...])]. This short-circuits the deep analysis for that method and builds the page-prop type directly from your cast map:
use AbeTwoThree\LaravelTsPublish\Attributes\TsCasts;
#[TsCasts(['tag' => 'Tag', 'mode' => 'string'])]
public function create(): \Inertia\Response
{
return Inertia::render('Tags/TagCreate', [
'tag' => new Tag,
'mode' => 'create',
]);
}This produces:
export type CreatePageProps = Inertia.SharedData & {
tag: Tag;
mode: string;
};Removing the Route Entirely with #[TsExclude]
If you don't want a route helper generated at all, annotate the method with #[TsExclude]. This removes the route from the published output entirely — no route helper and no page-prop type. Use this only when the route should not appear in the TypeScript output.
Known Limitation: Taint Detection Depth
Taint is detected when a controller references a table through its own file, through a constructor parameter or typed property whose class file references a table, or through one level of $this->property->method(...) resources passed directly as the Inertia::render() data argument. Deeper indirection that still reaches a table — for example, a table reached only through app(...) / resolve(...) inside a method body, a trait method, a global helper, a nested array value, an array_merge(...), or a variable-assigned render argument — may not be auto-detected. In those cases ts:publish could still fatal when it attempts to evaluate the table. Use #[TsCasts] or #[TsExclude] on the method as a manual escape hatch.
Automatic Taint Skip: The Common CRUD Case
The most common pattern — a CRUD controller that injects a table-bearing resource through a constructor parameter or a typed property — is handled automatically without any restructuring. When ts:publish detects that a controller depends on a resource whose file references an Inertia UI Table, it skips deep analysis for every action on that controller. This means all sibling actions (index, create, store, update, destroy) are covered without annotating each method.
Dedicated-Controller Escape Hatch
When taint detection cannot reach a table — for example, the table is obtained through app(...) / resolve(...) inside a method body, a trait method, or a global helper rather than through a typed constructor or property dependency — the guaranteed fix is to isolate the table in a dedicated single-action __invoke controller marked #[TsExclude], and to remove the table from the shared CRUD resource so the rest of its routes are analyzed normally.
Example — before (table mixed into CRUD resource, detection may not reach it):
// app/Http/Resources/MerchandiseResource.php
class MerchandiseResource
{
public function index(Request $request): array
{
return [
'merchandise' => app(MerchandiseTable::class)->make(),
];
}
public function store(Request $request): array
{
// store merchandise logic here
return [];
}
}Example — after (table isolated, CRUD resource is table-free):
// app/Http/Controllers/Merchandise/MerchandiseIndexController.php
use AbeTwoThree\LaravelTsPublish\Attributes\TsExclude;
use App\Tables\MerchandiseTable;
use Inertia\Inertia;
#[TsExclude]
class MerchandiseIndexController
{
public function __invoke(Request $request): \Inertia\Response
{
return Inertia::render('Merchandise/MerchandiseIndex', [
'merchandise' => MerchandiseTable::make(),
]);
}
}
// app/Http/Controllers/Merchandise/MerchandiseController.php
// (resource no longer references the table)
class MerchandiseResource
{
public function store(Request $request): array
{
// store merchandise logic here
return [];
}
}// routes/web.php
Route::get('/merchandise', MerchandiseIndexController::class)->name('merchandise.index');
Route::resource('merchandise', MerchandiseController::class)->except('index');With #[TsExclude] on the dedicated controller, ts:publish skips that route entirely (no route helper, no page-prop type). The remaining CRUD routes — now backed by a table-free resource — type normally. This is the 100% robust fallback: no table class appears in any file that ts:publish statically analyzes.
The trade-off is a small per-table restructure: one extra single-action controller per table-rendered page. This is only necessary when the automatic taint skip cannot detect the table dependency. As explained in Why This Happens above, the root cause is the phpoffice/phpspreadsheet declaration-compatibility fatal — not the Inertia UI Table package — so fixing it upstream (or not installing the optional Excel export integration) eliminates the need for any of this.
Form Request Payload Types
When a controller action type-hints a Laravel FormRequest, the request's generated TypeScript interface is attached the same way — automatically, via annotateRequestPayload:
export const store = annotateRequestPayload<StorePostRequest>()(
defineRoute({
name: "posts.store",
url: "/posts",
methods: ["post"] as const,
}),
);Read it back with InferRequestPayload<typeof route>, e.g. to type an Inertia useForm() call:
import type { InferRequestPayload } from "@tolki/ts";
import { store } from "@js/types/data/app/http/controllers/post-controller";
const form = useForm<InferRequestPayload<typeof store>>({
title: "",
body: "",
});annotatePageProps and annotateRequestPayload are nested together when a single action needs both — again, generated automatically:
export const store = annotateRequestPayload<StorePostRequest>()(
annotatePageProps<StorePageProps>()(
defineRoute({
name: "inertia-form-request.store",
url: "/inertia-form-request",
methods: ["post"] as const,
component: "InertiaFormRequest/Success",
}),
),
);Filtering & Excluding Routes
#[TsExclude]on a controller class excludes the whole controller; on a single action method, it excludes just that route.routes.only/routes.except— arrays of route-name patterns supporting wildcards ('posts.*') and negation ('!posts.index'). Only one of the two should be set.routes.exclude_middleware— skip any route behind the listed middleware.routes.only_named— whentrue, only routes with an explicit->name(...)are published.
// config/ts-publish.php
'routes' => [
'only' => ['posts.*', '!posts.destroy'],
'exclude_middleware' => ['throttle'],
],Casing
routes.method_casing ('camel' (default), 'snake', or 'pascal') controls the casing of each exported action's identifier — it does not affect the Laravel route name, only the generated variable name.
Configuration Reference
The full list of routes.* and inertia.* config keys — including pipeline class overrides for advanced customization — lives in the Configuration Reference.
Type Reference
Exported from @tolki/ts (runtime) and @tolki/types (types only):
| Export | Description |
|---|---|
defineRoute() | Builds a route helper from compact metadata. |
annotatePageProps<T>() | Attaches an Inertia page-props phantom type (auto-applied in generated files). |
annotateRequestPayload<T>() | Attaches a FormRequest payload phantom type (auto-applied in generated files). |
InferPageProps<T> | Reads the page-props type back off a route. |
InferRequestPayload<T> | Reads the request-payload type back off a route. |
setRouteDefaults(), addRouteDefault(), getRouteDefaults(), resetRouteDefaults() | Manage global route parameter defaults. |
formSafeOptions() | The _method-spoofing helper used internally by .form.<verb>() — exposed for advanced manual use. |
RouteArgMeta, RouteMetadata, RouteQueryOptions | The metadata shapes accepted by defineRoute(). |
DefineRouteResult, RouteCallResult, RouteFormResult, RouteCallResultWithComponent | The shape of a route helper and its call results. |