Search by

stubbedev / laravel-stoli

stubbedev

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.

Package info

github.com/stubbedev/laravel-stoli

pkg:composer/stubbedev/laravel-stoli

Statistics

Installs: 2 017

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.2 2026-10-13 13:35 UTC

This package is auto-updated.

Last update: 2026-10-13 13:37:00 UTC


README

Software License

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 where constraints, enums, int arguments 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.js and stoli.d.ts become stoli.ts; the old files are removed on the next run.
  • typescript:transform generates Stoli's files too; 'transform' => false keeps them apart.
  • 'axios' => true still works; prefer 'client' => 'axios'.
  • createURLWithoutQuery(), which deleted keys from its argument, is resolve(), which returns them.
  • A request's parameters are its input type (StoreUserDataInput), not its Data type.
  • Unresolvable responses are unknown, not Record<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.