stubbedev / laravel-stoli
An enhanced Laravel extension to export named routes to JavaScript, allowing you to use route names instead of URLs. Supports route grouping for modular applications. Provides type hinting for route params and responses.
Requires
- php: ^8.2
- laravel/framework: ^12.0|^13.0
- phpstan/phpdoc-parser: ^2.0
- spatie/laravel-data: ^4
- spatie/laravel-typescript-transformer: ^3
- spatie/php-structure-discoverer: ^2.0
Requires (Dev)
- larastan/larastan: ^3
- mockery/mockery: ^1.6
- orchestra/testbench: ^10|^11
- phpunit/phpunit: ^11.5|^12
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Use your Laravel routes from TypeScript by name, fully typed. Stoli builds on
spatie/laravel-data and
spatie/laravel-typescript-transformer
and types every route from what Laravel already knows:
- parameters, narrowed by
whereconstraints, enums,intarguments and model binding - requests, from Data objects and FormRequests, nested and validated as Laravel takes them in
- responses and their status, wrapping and pagination included
- validation errors, keyed by the fields the route takes
- a typed fetch or axios client, tree-shakeable URL functions and your PHP constants
Requirements
PHP 8.2+, Laravel 12 or 13, spatie/laravel-data ^4, spatie/laravel-typescript-transformer ^3.
Installation
composer require stubbedev/laravel-stoli
php artisan vendor:publish --provider="Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerServiceProvider"
php artisan vendor:publish --tag=stoli
Stoli hooks into the transformer, so one command generates everything into its output directory:
php artisan typescript:transform # types and routes php artisan typescript:transform --watch # and keep them up to date
stoli:generate does the same on its own (with 'transform' => false it is the only way), and
stoli:generate --check writes nothing but fails when a generated file is missing, changed or
stale — for CI.
| File | What it holds |
|---|---|
stoli.ts |
the route service and its types |
api.ts, one per module |
the routes, and every type below |
router.ts, stoli-fetch.ts / stoli-axios.ts |
the HTTP client, when client is set |
api.urls.ts |
one URL function per route, when urls is on |
constants.ts |
the constants of #[TypeScriptConstants] classes |
Every generated file starts with // Generated by stoli:generate - do not edit., and only files
carrying it are ever removed.
Quick start
import routes from "./api"; import { createRoute } from "./stoli"; import { Stoli } from "./router"; const route = createRoute({ routes }); route("users.show", { user: 1 }); // "https://example.com/api/users/1" route("users.show", { user: "x" }); // error: whereNumber('user') takes a number route("users.show"); // error: `user` is required const { data, status } = await Stoli.post("users.store", { display_name: "Ann" }); data; // UserData status; // 201
Everything reads its types from one ApiRoutes definition per file, so createRoute(),
RouteService, the router and the URL functions can never disagree. It also exports
ApiRouteName, ApiRouteParams, ApiRouteResponse, ApiRouteStatus and
Api{Get,Post,Put,Patch,Delete}RouteName.
What gets typed
Parameters
Path and domain parameters are always typed; {param?} ones may be left out or null.
| Route / action | Type |
|---|---|
->whereNumber('id') |
number |
->whereUuid('id') |
`${string}-${string}-${string}-${string}-${string}` |
->whereIn('type', ['a', 'b']) |
'a' | 'b' |
->whereIn('version', [1, 2]) |
'1' | 1 | '2' | 2 |
Status $status, a backed enum |
'active' | 'archived' |
int $page |
number |
Post $post, bound by its integer key |
number |
Article $article, bound by a slug or {article:slug} |
string |
| anything else | string | number |
A constraint wins over the signature. When the action takes a Data object or FormRequest, its input type is added; otherwise any other parameter goes out as the query string.
Requests
Each request gets an exported input type named after its class (StoreUserDataInput,
StoreArticleRequestInput), ready for typing forms.
A Data object is typed the way laravel-data takes it in, not the way it sends it back:
input names (#[MapInputName]), optional only where a default, null or Optional can stand
in, #[Computed] properties left out, and nested Data objects and collections of them taken
in as their own input types, however deep, cycles included. Validation attributes narrow it:
| Attribute | Input |
|---|---|
#[In('a', 'b')], #[Enum(Status::class)] |
'a' | 'b', the enum's values |
#[Uuid] |
a UUID-shaped string |
#[Accepted], #[Declined] |
the values Laravel accepts as such |
#[Required] |
required and never null |
#[Prohibited] |
left out |
#[Confirmed] |
adds {field}_confirmation |
A FormRequest, or any subclass of one, is typed by its rules(), its parents' included;
one without rules, which only authorizes, leaves the route open like an action without one.
meta.title nests, authors.*.name makes { name: string }[], and:
| Rules | Input |
|---|---|
required / nullable / sometimes, required_if, none |
required, never null / | null / optional |
prohibited, missing |
left out |
string, email, date, … / integer, numeric, … |
string / number |
boolean |
boolean | 0 | 1 | '0' | '1' |
in:a,b, Rule::in(), Rule::enum() (with only/except) |
the literal values |
file, image, mimes, File::types() |
Blob |
array |
its .* or dotted rules, or unknown[] |
confirmed |
adds {field}_confirmation |
Responses
From the action's @return tag, or its return type. PHPDoc types are converted recursively.
| Returns | Response |
|---|---|
UserData, ?UserData, void |
UserData, UserData | null, void |
@return ApiResponseData<list<UserData>|null> |
ApiResponseData<UserData[] | null> |
@return DataCollection<int, UserData>, Collection<int, UserData>, UserData[] |
UserData[] |
@return array<string, UserData> |
Record<string, UserData> |
@return array{user: UserData, total?: int} |
{ user: UserData; total?: number } |
@return PaginatedDataCollection<int, UserData> |
Paginated<UserData> (also CursorPaginated) |
| anything unresolvable | unknown |
Wrapping follows laravel-data: a class's defaultWrap(), else the data.wrap config key, unless
the class overrides toResponse(). The status follows it too: 201 for a Data response to a
POST, 200 otherwise, number when the action builds its own response.
Validation errors
try { await Stoli.post("orders.store", form); } catch (error) { const failed = Stoli.validationErrors("orders.store", error); // null unless a 422 failed?.errors["shipping.street_name"]; // string[] | undefined failed?.errors["lines.0.sku"]; // string[] | undefined failed?.errors["nope"]; // error: not a field of the route }
HTTP router
'client' => 'fetch', // or 'axios' (npm install axios), or null
router.ts (<module>.router.ts with several modules) exports Stoli with get, post,
put, patch and delete, each taking only the routes answering to it. Both clients take
the same calls, respond { data, status, headers } and throw outside 2xx, so switching is a
config change. The fetch client asks for JSON the way Laravel expects and forwards the
XSRF-TOKEN cookie like axios does. Build your own to configure it:
import { createRouter } from "./stoli-fetch"; import routes from "./api"; export const Stoli = createRouter(routes, { rootUrl: "http://localhost:8000", init: { credentials: "include" } }); // stoli-axios: createRouter(routes, { client: myAxiosInstance })
Files can sit anywhere in the parameters: the body then goes out as multipart, flattened the
way PHP reads it back (meta[tags][], booleans as 1/0), and a put or patch carrying
files is sent as a POST with _method, since PHP only parses multipart on POST. A raw
FormData works too; route parameters are read from it and stripped from the body.
URL functions
With 'urls' => true, api.urls.ts exports one typed function per route, which a bundler drops
when unused:
import { usersShow } from "./api.urls"; import { setRootUrl } from "./stoli"; usersShow({ user: 1 }); // "https://example.com/api/users/1" setRootUrl("http://localhost:8000"); // e.g. for a dev proxy
Constants
#[TypeScriptConstants] // or #[TypeScriptConstants('Name')] to rename it final class Permission // in App\Support { public const VIEW = 'view'; public const EDIT = 'edit'; }
import { App, type Permission } from "./constants"; App.Support.Permission.VIEW; // 'view' const granted: Permission = "edit"; // 'view' | 'edit'
Public constants declared on the class itself are exported: scalars, arrays, enum cases (as
their value or name) and JsonSerializable values. Classes with spatie's #[TypeScript] are
picked up too, unless constants.attributes says otherwise; enums are left to the transformer.
Route service
| Method | |
|---|---|
generateFullURL(name, params?) |
the URL, with the parameters the path does not take as the query string |
resolve(name, params?) |
{ url, rest }: the URL without a query, and the parameters left over |
has(name) |
whether the route exists; narrows a string to a route name |
new RouteService({ routes, rootUrl }) swaps the host of every route without a domain of its
own. A missing required parameter throws; query strings are written the way PHP's
http_build_query reads them back.
Configuration
return [ 'split' => true, // one file per module, or all in `single.name` 'transform' => true, // generate with typescript:transform 'client' => null, // 'fetch', 'axios' or null 'urls' => false, // per-route URL functions 'import_aliases' => [], // e.g. ['@' => base_path('frontend')] 'single' => ['name' => 'api'], 'constants' => ['enabled' => true, 'name' => 'constants', 'path' => null, 'paths' => null], 'modules' => [ ['match' => '*', 'name' => 'api', 'rootUrl' => env('APP_URL'), 'absolute' => true], ], ];
With import_aliases, the generated files import one another through your tsconfig paths
rather than relatively. Map each alias to the absolute directory it stands for, without a
trailing slash on the alias: with ['@' => base_path('frontend')] and "paths": { "@/*": ["./*"] }
in frontend/tsconfig.json, a router in frontend/src/types imports @/src/types/api, not
./api. A file under several aliased directories goes through the deepest; a file under none
is imported relatively.
| Module option | Default | |
|---|---|---|
name |
— | the file name, api → api.ts |
match |
* |
the path its routes are at or under: /api/store takes api/store/…, not api/storefront |
names |
null |
Str::is patterns the route name must match too, e.g. app.* |
rootUrl |
APP_URL |
the host of absolute URLs; a Route::domain() keeps its own |
absolute |
true |
absolute or relative URLs |
prefix |
null |
a path prefix for every URL |
stripPrefix |
null |
a route name prefix to drop: store. turns store.cart.show into cart.show |
path |
the transformer output | where the file is written |
standalone |
false |
its own file even when split is off, and no router (e.g. a SPA's pages) |
Upgrading from 0.x
- spatie/laravel-data 4 is required.
stoli.jsandstoli.d.tsbecomestoli.ts; the old files are removed on the next run.typescript:transformgenerates Stoli's files too;'transform' => falsekeeps them apart.'axios' => truestill works; prefer'client' => 'axios'.createURLWithoutQuery(), which deleted keys from its argument, isresolve(), which returns them.- A request's parameters are its input type (
StoreUserDataInput), not its Data type. - Unresolvable responses are
unknown, notRecord<string, unknown>.
Development
just test # phpunit, runtime checks, and the generated TypeScript under tsc just analyse # larastan at max level just e2e # every end-to-end scenario in a real Laravel app just e2e 8.2 12 20 5.8 # on PHP, Laravel, Node and TypeScript versions of your choice
The e2e suite installs Stoli from the working copy into a fresh Laravel application and runs
every combination of writer, split, client, mode, formatter and URL functions: it generates,
checks that --check agrees and a rerun rewrites nothing, compiles under the strictest
compiler options, then makes real requests to php artisan serve and compares every URL with
Laravel's route(). Watch mode is tested too. CI runs it on PHP 8.2–8.5, Laravel 12 and 13,
Node 20–24 and TypeScript 5.8 and latest.
License
MIT. See LICENSE.