Inertia
The Laravel TypeScript Publisher reads the share() method of your HandleInertiaRequests middleware and writes inertia-config.d.ts. That file declares a global Inertia.SharedData type and augments @inertiajs/core, so every Inertia page gets typed shared props without a type you maintain by hand.
This page covers shared data, and the API resources that shared data and a route's page props hold. A route's own page props, meaning the component field and the page-props type on its route helper, are covered in Inertia Integration on the Routing page.
How the Augmentation File Is Generated
With inertia.enabled on (the default), the package builds the file from your middleware:
- Finding the middleware: the package searches
inertia.inertia_middleware_path, orapp_path()when that isn't set, for a class that extendsInertia\Middleware. If it finds none, it writes no file. If your app has more than one such class, pointinertia.inertia_middleware_pathat the directory that holds the one you want. - Reading
share(): every key's value is typed from your code, without handling a real request. Ashare()that a trait supplies is read too, and so is one that builds its array in a variable and returns it. A key that the variable gets on only some paths, such as inside anif, is optional. - Parent middleware: a
...parent::share($request)spread andarray_merge(parent::share($request), [...])are both read, all the way up your middleware's parent classes. A later key overrides an earlier one and keeps the earlier one's position, as PHP does. - Integer keys: Inertia merges shared data and page props with
array_merge(), which renumbers integer keys from0. A top-level42 => …key therefore publishes as"0", andts:publishwarns that you probably meant a string key. Nested keys keep theirs. $request->user(): typed through your live auth config, fromauth.defaults.guardto that guard's provider to the provider'smodel. The prop becomesUser | null, and the file imports the model's type for you.auth()->user(),auth()->id(),Auth::user()andAuth::id()resolve the same way.- Request helpers:
$request->url(),fullUrl(),path(),integer(),boolean(),string(),cookie()andhasCookie()are typed from Laravel's own signatures. config():config('some.key')with a literal key is typed from the live config value, since the package runs inside your booted app. A computed key staysunknown.- Value helpers:
now()andtoday()are typed asstring, orDateundertimestamps_as_date.str($value)is typed asstring, andstr()with no argument asRecord<string, never>.url($path)is typed asstringwhen the path can't benull, and staysunknownwhen it can or has no type.collect([...])and a bare array literal are both typed as the list or object the array encodes as, such asstring[]for['a', 'b'].+on two arrays is typed as PHP's array union, where the left side's keys win. - Closures and first-class callables: Inertia calls a callable prop before it sends it, so closures and first-class callables, such as
fn () => $request->url()andnow(...), are typed from what the call returns. - Inertia v2 prop wrappers:
Inertia::defer(),optional(),lazy(),always(),merge()anddeepMerge()type as the value they wrap. The three a partial reload can leave out,defer,optionalandlazy, make the key optional. - API resources: a resource or resource collection is typed as the response Inertia sends for it, as API Resources in Props describes.
EnumResourceprops: a prop that wraps an enum inEnumResource, such as'role' => EnumResource::make(Role::Admin), becomesAsEnum<typeof Role>whenenums.use_tolki_packageis on (the default), the same as in API Resources. With it off, the key keeps the enum's type alias (role: RoleType). The change applies per key, so another key that reads the enum directly keepsRoleType.- Enum cases: a bare case, such as
'defaultStatus' => Status::Draft, is typed as the enum's type alias (defaultStatus: StatusType), which matches the value Laravel sends for a backed enum. The file imports the alias for you. errors: left out.@inertiajs/corealready typespage.props.errorsasErrors & ErrorBag, anderrorValueTypesharpens it. A#[TsCasts]or@returndocblock entry namederrorsstill adds one if you want it.- The output file: the result is written to
inertia-config.d.ts. Setinertia.augmentation_filenameto change the name.
auth()->id() and Auth::id() publish the type of the column the auth model identifies users by, with | null. That's the model's key, such as number | null, unless the model's getAuthIdentifierName() returns a literal column name. A model whose getAuthIdentifierName() returns 'email' publishes string | null. An identifier column cast to an enum publishes unknown.
When the package can't create an instance of the model, it reads the key type from the class, and publishes unknown if code works the key out. A getAuthIdentifierName() that works the column out in code, such as from config, publishes unknown too, and so does an override of getAuthIdentifier(), since only running your app shows which value the ID holds.
In a route's page props, getKey() on a route-bound model publishes the model's key type with no | null, because Laravel binds only a saved row. With a Post $post action parameter, 'post_id' => $post->getKey() publishes post_id: number.
A Ternary Between Two Different Enums
A key whose ternary arms wrap different enums, such as $cond ? EnumResource::make(Role::Admin) : EnumResource::make(Status::Draft), renders as RoleType | StatusType with no import for either name. Your build then fails with a TS2304 error for each. Give both arms the same enum, or override that key with a #[TsCasts] entry that has an import.
Anatomy of the Generated File
Given this middleware:
class HandleInertiaRequests extends Middleware
{
protected $withAllErrors = true;
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => ['user' => $request->user()],
'ziggy' => fn () => ['location' => $request->url()],
'sidebarOpen' => ! $request->hasCookie('sidebar_state'),
'appName' => config('app.name'),
];
}
}The package generates inertia-config.d.ts:
import type { User } from "./app/models";
declare global {
namespace Inertia {
type SharedData = {
auth: { user: User | null };
ziggy: { location: string };
sidebarOpen: boolean;
appName: string;
};
}
}
declare module "@inertiajs/core" {
export interface InertiaConfig {
sharedPageProps: {
auth: { user: User | null };
ziggy: { location: string };
sidebarOpen: boolean;
appName: string;
};
errorValueType: string[];
}
}
export {};Each part of the file does one job:
import type { User } from "./app/models";: every model, resource or enum that a prop type names gets an import at the top, with a path relative to the output root. AnEnumResourceprop is the exception under the defaultenums.use_tolki_package. It renders asAsEnum<typeof Role>, so the enum's const is imported as a value (import { Role } from './app/enums';), under animport { type AsEnum } from '@tolki/ts';line, and both come before theimport typelines. Imports from animportkey in#[TsCasts]join theimport typelines.declare global { namespace Inertia { type SharedData = ...; } }: makesInertia.SharedDataavailable by name in any.tsfile in your project. That includes the generated route files, which combine it with each page's props, as Inertia Integration shows.declare module '@inertiajs/core' { ... }: augments Inertia's ownInertiaConfig, sousePage().propsis typed across your frontend without you writing the augmentation.errorValueType: string[]: added only when the middleware setsprotected $withAllErrors = true;. It matches the shape Inertia uses for its validation errors in that mode.export {};: TypeScript accepts adeclare globalblock only in a module, a file with at least one top-levelimportorexport. This line makes the file a module even when it has no imports.
The file imports only the names its types use. A key you override with #[TsCasts] drops the import its inferred type needed.
A key that isn't a plain identifier, such as 'can-edit' or 42, is quoted, as in "can-edit": boolean. Route files quote a page prop's key the same way.
A value the package can't read stays unknown. 'flash' => ['success' => fn () => $request->session()->get('success')] would publish flash: { success: unknown }, because session() isn't one of the typed request methods. Give such a key its type with #[TsCasts] or a @return docblock.
Type Resolution Priority
Each key from share() takes its type from the first of these that covers it:
#[TsCasts]on the middleware class or on itsshare()method. When both name a key, the method's entry wins, even when the two spell an index signature's name differently. It's the same attribute models, API resources and broadcast events use.- A
@return array{...}docblock onshare(). You write the shape by hand, for a key the package can't infer, such as a method call whose return type says nothing. Akey?:entry makes that key optional. - Inference from your code, the default. It covers plain values, nested arrays, conditionals, closures, spreads,
array_merge(),config(), the request and auth helpers, and Inertia's prop wrappers.
A key that #[TsCasts] or the docblock names but share() doesn't return is added to the type.
In a #[TsCasts] entry, 'optional' => true adds a ? and 'optional' => false removes one. An entry that leaves out 'optional' takes the flag from the next source down. For an entry on share(), that's first the class's entry for the same key, if it sets 'optional'. Next comes the docblock, where key?: makes the key optional and key: makes it required. Inference decides a key neither names.
An entry on a controller method for a route's page props takes 'optional' the same way. Without it, the prop keeps its own ?.
In this middleware, one key takes its type from each override:
#[TsCasts(['appName' => 'string'])]
class HandleInertiaRequests extends Middleware
{
/**
* @return array{flash: array{success: string|null, error: string|null}}
*/
public function share(Request $request): array
{
return [
...parent::share($request),
'appName' => config('app.name'),
'flash' => $this->resolveFlashMessages($request), // a method whose return type says nothing
];
}
}Here appName takes its type from #[TsCasts] and flash takes its type from the @return docblock. Any other key that share() returns falls back to inference.
API Resources in Props
Inertia sends an API resource prop as the resource's whole response, the same JSON a route that returns the resource sends. That holds at any depth of a route's page props and in share(), so each prop publishes that response:
return Inertia::render('Posts/Show', [
'post' => new PostResource($post),
'comments' => CommentResource::collection($post->comments),
'tags' => new TagCollection($post->tags),
]);The page-props type matches what Inertia sends:
export type ShowPageProps = Inertia.SharedData & {
post: { data: PostResource };
comments: AnonymousResourceCollection<CommentResource>;
tags: TagCollection;
};Each kind of prop follows its own rule:
- A single resource, from
new,make(), ortoResource(), publishes{ data: PostResource }, because Laravel sends it under thedatakey. Code that readsprops.post.titledoesn't compile, since Laravel sends the title atprops.post.data.title. A resource whosetoArray()already returns its wrap key, such asdata, isn't wrapped again, andEnumResource::make()keepsAsEnum<typeof Role>, becauseEnumResourceturns wrapping off. Resource::collection()publishesAnonymousResourceCollection<CommentResource>from@tolki/types, which is{ data: CommentResource[] }.- A named collection publishes its own interface, which already holds its wrap key. One whose
toArray()returns no wrap key publishes{ data: TagCollection }, because Laravel nests that body underdata. ->toResourceCollection()with no argument publishes the collection Laravel guesses. Over an empty collection, Laravel skips the guess and sends{ data: [] }, so the prop also publishes that empty response, such asUserCollection | { data: never[] }or, overpaginate(),| JsonResourcePaginator<never>. A guessed collection whose response is only itsdatalist already fits it and gets no extra type.
The wrap key is the one your app sets by the time ts:publish runs, as The Wrap Key Your App Sets describes. After JsonResource::withoutWrapping(), a single resource publishes the bare PostResource, and Resource::collection() over items that aren't paginated publishes CommentResource[]. Over a paginator, Laravel keeps the data key. After JsonResource::wrap('items'), a single resource publishes { items: PostResource }.
Some props publish less than Inertia sends. The keys that additional() or a resource's with() method add beside data aren't published, and after JsonResource::withoutWrapping() those keys still make Laravel nest the resource under data, while the package publishes it bare. Resource::collection(...)->additional(...) publishes unknown. Props that another class's method builds, as in Inertia::render('Posts/Show', $this->props->build()), publish each API resource inside them bare, though Inertia sends it under data. Type such a prop with #[TsCasts].
JSON:API Resources in Props
A JSON:API resource prop publishes the document Inertia sends, with the document types from @tolki/types. Here PostResource extends JsonApiResource, and its one relationship, author, is a UserResource:
return Inertia::render('Posts/Index', [
'post' => new PostResource($post),
'posts' => PostResource::collection(Post::all()),
'paged' => PostResource::collection(Post::query()->paginate(10)),
]);The page-props type names each document:
export type IndexPageProps = Inertia.SharedData & {
post: JsonApiDocument<PostResource, UserResource>;
posts: JsonApiCollection<PostResource, UserResource>;
paged: JsonApiPaginator<PostResource, UserResource>;
};The second type parameter lists every resource object that an ?include= request can add to included, a union when there are several, through the relationships of each related resource too. A related model with no JSON:API resource of its own adds Laravel's fallback, such as JsonApiResourceObject<"reports">. Over simplePaginate() and cursorPaginate(), the prop publishes JsonApiSimplePaginator and JsonApiCursorPaginator. toResource() and toResourceCollection() publish the same documents.
After JsonResource::withoutWrapping(), PostResource::collection() over items that aren't paginated publishes the bare PostResource[]. A request with ?include=, or any request once JsonApiResource::configure() sets the jsonapi member, still sends it under data, and the published type doesn't cover that.
Preserve-Keys Resource Collections in Page Props
This section covers resource collections in a route's page props. A collection that share() returns is typed the same way. See Inertia Integration for how page props are typed in general.
A ResourceCollection, or a resource collected with Resource::collection(), can keep its source collection's keys through the #[PreserveKeys] attribute or the older public $preserveKeys = true; property. Laravel then serializes its data as a JSON object keyed by those keys, not as an array. A paginated page prop backed by such a collection types data to match:
import type { JsonResourcePaginator } from "@tolki/types";
// $wrap = null (flat), or Resource::collection($paginator) on a preserve-keys resource:
export type TeamsPageProps = Inertia.SharedData & {
teams: Omit<JsonResourcePaginator<Team>, "data"> & {
data: Record<string, Team>;
};
};JsonResourcePaginator<T> types its data as T[] (see Paginated Collections on the API Resources page), so a key-preserving collection can't use it as is. The page-props type drops the array data with Omit and adds a keyed Record<string, T> in its place. Over simplePaginate() or cursorPaginate(), it starts from JsonResourceSimplePaginator<T> or JsonResourceCursorPaginator<T> instead.
A named collection that wraps its items in a data key, such as new TeamCollection($paginator), doesn't need this. Its page prop is the collection's own interface plus pagination (TeamCollection & ResourcePagination), and that interface already types data as Record<string, T> when the collection keeps keys, paginated or not. Only the flat collection and the anonymous Resource::collection() form need the Omit<...> & { data: Record<...> } shape.
Paginating Inline in the Render Call
You don't have to assign a paginator to a variable first. Both of these produce the same page-props type:
// Through a variable
$teams = Team::query()->paginate(10);
return Inertia::render('Teams/Index', [
'teams' => new TeamCollection($teams),
]);
// Inline, with no variable
return Inertia::render('Teams/Index', [
'teams' => new TeamCollection(Team::query()->paginate(10)),
]);Both forms give the collection's interface plus pagination:
export type IndexPageProps = Inertia.SharedData & {
teams: TeamCollection & ResourcePagination;
};paginate(), simplePaginate(), and cursorPaginate() are all recognized, in the new SomeCollection(...), SomeResource::collection(...), and ->toResourceCollection() forms.
Each paginator sends its own meta block, so each gets its own type from @tolki/types. A named collection adds the pagination type to its own interface. An anonymous collection, from SomeResource::collection(...), publishes the paginator's whole response, and so does a collection with $wrap = null, because Laravel sends it under data over a paginator. In this list, the named form comes first, and R is the collected resource:
paginate():TeamCollection & ResourcePagination, orJsonResourcePaginator<R>.simplePaginate():TeamCollection & ResourceSimplePagination, orJsonResourceSimplePaginator<R>.cursorPaginate():TeamCollection & ResourceCursorPagination, orJsonResourceCursorPaginator<R>.
A prop that can hold more than one paginator kind, such as a ternary over paginate() and simplePaginate(), publishes TeamCollection & (ResourceSimplePagination | ResourcePagination). One that can hold a plain collection or a paginator publishes both shapes, TeamCollection | (TeamCollection & ResourcePagination). A collection that share() returns is typed the same way.
A Missed Paginator Gives a Wrong Type
When the package doesn't see a paginator, the prop doesn't lose its type. It gets the collection's type without the pagination part, which is wrong rather than missing. The package sees a paginator that a query chain starting at the model returns, such as Post::query()->simplePaginate(10), or one held in a local variable assigned that way. It doesn't follow a paginator from a query builder held in a variable, from a controller method such as $this->page(), or from a method of an injected service:
$q = Post::query();
return Inertia::render('Posts/Index', [
'posts' => new PostCollection($q->paginate(10)), // not detected
]);Here posts types as PostCollection, without & ResourcePagination, and the same paginator passed without a resource publishes unknown. Build the paginator in the props, or type the prop with #[TsCasts].
Output Location
The package writes the augmentation file to the first of these directories that's set:
inertia.output_directory.routes.output_directory. Route files referenceInertia.SharedData, so when you move them, the augmentation file follows.- The global
output_directory.
Configuration Reference
The Configuration Reference lists every inertia.* key, including component_casing and ui_table_package, which apply to route page props.