oybek-daniyarov / laravel-trpc
End-to-end type-safe APIs for Laravel. Like tRPC, but for Laravel + TypeScript.
Installs: 3
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/oybek-daniyarov/laravel-trpc
Requires
- php: ^8.2
- laravel/framework: ^11.0|^12.0
- laravel/ranger: 0.1.*
- laravel/surveyor: 0.1.*
- spatie/laravel-data: ^4.0
- spatie/laravel-typescript-transformer: ^2.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan: ^2.0
This package is auto-updated.
Last update: 2026-01-21 22:45:36 UTC
README
End-to-end type-safe APIs for Laravel. Like tRPC, but for Laravel + TypeScript.
Built with AI. This package was developed using Claude Code — architected and directed by a human, implemented by AI. Use at your own risk. Read the backstory.
Generate a fully typed TypeScript client from your Laravel routes with zero runtime overhead. Get autocomplete for route names, request bodies, response types, and URL parameters.
Features
- Full Type Safety: Request bodies, responses, URL parameters, and query strings are all typed
- Zero Runtime Overhead: Types are generated at build time, no runtime reflection
- Framework Integrations: Built-in support for React Query and Inertia.js
- Grouped API Client: Object-based API (
api.users.show()) with full autocomplete - Postman Export: Generate Postman collections from your routes
Example Project
See it in action — a Laravel API with Inertia.js frontend, fully typed end-to-end:
🔗 github.com/oybek-daniyarov/empty-space
Table of Contents
- Example Project
- Requirements
- Installation
- Quick Start
- TypedRoute Attribute
- Generate & Use
- Generated Files
- Configuration
- Middleware & Authentication
- Error Handling
- Type Helpers
- React Query Integration
- Inertia.js Integration
- API Client Configuration
- Command Options
- Customizing Stubs
- Backstory
Requirements
- PHP 8.2+
- Laravel 11.x or 12.x
- spatie/laravel-data
- spatie/laravel-typescript-transformer
Installation
composer require spatie/laravel-data spatie/laravel-typescript-transformer composer require oybek-daniyarov/laravel-trpc
Publish the config file:
php artisan vendor:publish --tag=trpc-config
Quick Start
1. Define Data Classes
use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; #[TypeScript] class UserData extends Data { public function __construct( public int $id, public string $name, public string $email, ) {} } #[TypeScript] class CreateUserData extends Data { public function __construct( public string $name, public string $email, public string $password, ) {} }
2. Add TypedRoute Attribute to Controllers
use OybekDaniyarov\LaravelTrpc\Attributes\TypedRoute; class UserController extends Controller { #[TypedRoute(response: UserData::class, isPaginated: true)] public function index() { return UserData::collect(User::paginate()); } #[TypedRoute(response: UserData::class)] public function show(User $user) { return UserData::from($user); } #[TypedRoute(request: CreateUserData::class, response: UserData::class)] public function store(CreateUserData $data) { return UserData::from(User::create($data->toArray())); } #[TypedRoute(request: UpdateUserData::class, response: UserData::class)] public function update(User $user, UpdateUserData $data) { $user->update($data->toArray()); return UserData::from($user); } #[TypedRoute] public function destroy(User $user) { $user->delete(); return response()->noContent(); } }
TypedRoute Attribute
The #[TypedRoute] attribute explicitly declares request and response types for your API endpoints. When applied, it takes priority over static analysis for type detection.
Parameters
| Parameter | Type | Description |
|---|---|---|
request |
class-string|null |
Request body Data class (for POST/PUT/PATCH) |
query |
class-string|null |
Query parameters Data class (for GET requests) |
response |
class-string|null |
Response Data class |
errorResponse |
class-string|null |
Error response Data class (defaults to ValidationError) |
isCollection |
bool |
Response is an array of items (Array<T>) |
isPaginated |
bool |
Response is paginated (PaginatedResponse<T>) |
Examples
Basic response type:
#[TypedRoute(response: UserData::class)] public function show(User $user) { return UserData::from($user); }
Request and response types:
#[TypedRoute(request: CreateUserData::class, response: UserData::class)] public function store(CreateUserData $data) { return UserData::from(User::create($data->toArray())); }
Query parameters (for GET with filters/search):
#[TypedRoute(query: UserFilterData::class, response: UserData::class, isPaginated: true)] public function index(UserFilterData $filters) { return UserData::collect( User::filter($filters)->paginate() ); }
Paginated response:
#[TypedRoute(response: UserData::class, isPaginated: true)] public function index() { return UserData::collect(User::paginate()); } // TypeScript: PaginatedResponse<UserData>
Collection response (non-paginated array):
#[TypedRoute(response: UserData::class, isCollection: true)] public function all() { return UserData::collect(User::all()); } // TypeScript: Array<UserData>
No response body (204 No Content):
#[TypedRoute] public function destroy(User $user) { $user->delete(); return response()->noContent(); } // TypeScript: void
Custom error response:
#[TypedRoute(
request: CreateUserData::class,
response: UserData::class,
errorResponse: CreateUserErrorData::class
)]
public function store(CreateUserData $data)
{
// ...
}
Query vs Request
- Use
requestfor body data (POST, PUT, PATCH requests) - Use
queryfor URL query parameters (GET requests with filters, search, pagination)
// GET /api/users?status=active&sort=name #[TypedRoute(query: UserFilterData::class, response: UserData::class, isPaginated: true)] public function index(UserFilterData $filters) { } // POST /api/users (body: { name: "John", email: "john@example.com" }) #[TypedRoute(request: CreateUserData::class, response: UserData::class)] public function store(CreateUserData $data) { }
Generate & Use
Generate TypeScript Client
php artisan trpc:generate
4. Use in TypeScript
import { api } from '@/api'; // Full autocomplete and type safety const users = await api.users.index(); const user = await api.users.show({ user: 1 }); const newUser = await api.users.store({ name: 'John', email: 'john@example.com', password: 'secret' }); await api.users.update({ user: 1 }, { name: 'Jane' }); await api.users.destroy({ user: 1 });
Generated Files
| File | Description |
|---|---|
types.ts |
Core types (HttpMethod, ApiError, PaginatedResponse, ValidationError) |
routes.ts |
Route definitions, RouteTypeMap, method-filtered route types |
helpers.ts |
Type helpers (RequestOf, ResponseOf, ParamsOf, QueryOf) |
url-builder.ts |
Type-safe URL builder with query string support |
fetch.ts |
Low-level fetch wrapper with full type safety |
client.ts |
Configurable API client factory with method-specific calls |
api.ts |
Grouped API client (api.users.show()) |
queries.ts |
React Query hooks organized by resource |
react-query.ts |
React Query utilities (queryKey, createQueryOptions) |
inertia.ts |
Inertia.js helpers (route, visit, formAction) |
index.ts |
Barrel exports |
README.md |
Generated documentation |
Configuration
// config/trpc.php return [ // Output directory for generated files 'output_path' => resource_path('js/api'), // API route prefix filter 'api_prefix' => 'api', // Route collection mode: 'api', 'web', 'all', 'named', 'attributed' 'route_mode' => 'api', // Exclude patterns 'exclude_patterns' => [ 'debugbar.*', 'horizon.*', 'telescope.*', ], // Output files to generate 'outputs' => [ 'routes' => true, 'types' => true, 'helpers' => true, 'url-builder' => true, 'fetch' => true, 'client' => true, 'index' => true, 'readme' => true, 'grouped-api' => true, 'inertia' => true, 'react-query' => false, 'queries' => false, ], ];
Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
output_path |
string | resource_path('js/api') |
Directory for generated TypeScript files |
api_prefix |
string | 'api' |
API route prefix for filtering |
version |
string | 'v1' |
API version identifier |
route_mode |
string | 'api' |
Route collection mode (see below) |
include_patterns |
array | [] |
Route patterns to include |
exclude_patterns |
array | [...] |
Route patterns to exclude |
exclude_methods |
array | ['options', 'head'] |
HTTP methods to skip |
preset |
string|null | null |
Framework preset: 'inertia', 'api', 'spa' |
outputs |
array | [...] |
Files to generate |
auto_typescript_transform |
bool | true |
Auto-run typescript:transform |
laravel_types_path |
string|null | null |
Path to laravel.d.ts |
Route Modes
| Mode | Description |
|---|---|
'api' |
Only routes starting with api_prefix (default) |
'web' |
Only routes NOT starting with api_prefix |
'all' |
All routes (use with include/exclude patterns) |
'named' |
Only routes with names |
'attributed' |
Only routes with #[TypedRoute] attribute |
Presets
Use presets to quickly enable common output configurations:
// config/trpc.php return [ 'preset' => 'spa', // 'inertia', 'api', or 'spa' ];
| Preset | Enables | Use Case |
|---|---|---|
'inertia' |
Core files + Inertia helpers | Laravel + Inertia.js apps |
'api' |
Core files + React Query | API-first / SPA with React Query |
'spa' |
Core files + Inertia + React Query | Full-featured SPA |
null |
Custom (configure outputs manually) |
Fine-grained control |
Presets override the outputs array. To customize individual outputs, set preset to null and configure outputs directly.
Postman Configuration
'postman' => [ 'output_path' => storage_path('app/postman'), 'collection_name' => env('APP_NAME', 'API').' Collection', 'base_url' => '{{base_url}}', 'auth_type' => 'bearer', // 'bearer', 'apikey', or null 'default_headers' => [], ],
Middleware & Authentication
The generated routes include middleware information, allowing you to build auth-aware UIs.
Generated Route Data
Each route includes middleware and authentication info:
// In routes.ts export const routes = { 'users.index': { path: 'api/users', method: 'get', params: [], middleware: ['auth:sanctum', 'verified'] as const, authenticated: true, }, 'auth.login': { path: 'api/auth/login', method: 'post', params: [], middleware: [] as const, authenticated: false, }, } as const;
Type Helpers for Auth Routes
Filter routes by authentication requirement:
import type { AuthenticatedRoutes, PublicRoutes } from '@/api'; // Only routes that require authentication type ProtectedRoutes = AuthenticatedRoutes; // 'users.index' | 'users.store' | 'users.update' | ... // Only routes that don't require authentication type OpenRoutes = PublicRoutes; // 'auth.login' | 'auth.register' | ...
Checking Auth Before API Calls
import { routes, type RouteName } from '@/api'; function isAuthRequired(name: RouteName): boolean { return routes[name].authenticated; } // Use in components function ApiButton({ route, children }: { route: RouteName; children: React.ReactNode }) { const { isAuthenticated } = useAuth(); if (routes[route].authenticated && !isAuthenticated) { return <LoginPrompt />; } return <button onClick={() => callApi(route)}>{children}</button>; }
Accessing Middleware Array
import { routes } from '@/api'; // Get middleware for a route const middleware = routes['users.index'].middleware; // ['auth:sanctum', 'verified'] // Check for specific middleware const requiresVerification = middleware.includes('verified');
Error Handling
Built-in Error Types
The generated types.ts includes standard Laravel error types:
// Base API error (thrown by fetch wrapper) interface ApiError { readonly message: string; readonly status: number; // HTTP status code readonly statusText?: string; // HTTP status text readonly errors?: Record<string, string[]>; // Validation errors } // Specific error types interface ValidationError { message: string; errors: Record<string, string[]>; } interface NotFoundError { message: string; } interface UnauthorizedError { message: string; } interface ForbiddenError { message: string; } interface ServerError { message: string; exception?: string; trace?: [...]; }
Custom Error Types with errorResponse
Define custom error Data classes for specific routes:
// Define a custom error type #[TypeScript] class CreateUserErrorData extends Data { public function __construct( public string $message, public ?string $email_suggestion, // Custom field public ?array $password_requirements, ) {} } // Use in controller #[TypedRoute( request: CreateUserData::class, response: UserData::class, errorResponse: CreateUserErrorData::class // Custom error type )] public function store(CreateUserData $data) { // If validation fails, return custom error structure if (User::where('email', $data->email)->exists()) { return response()->json([ 'message' => 'Email already taken', 'email_suggestion' => $data->email . '.new', ], 422); } return UserData::from(User::create($data->toArray())); }
Using Error Types in TypeScript
import type { ErrorOf, ApiError } from '@/api'; import { api } from '@/api'; // Get the error type for a specific route type CreateUserError = ErrorOf<'users.store'>; // CreateUserErrorData (custom) or ValidationError (default) // Handle errors with proper typing async function createUser(data: CreateUserData) { try { return await api.users.store(data); } catch (e) { const error = e as ApiError; console.log(error.status); // 422 console.log(error.message); // "Email already taken" console.log(error.errors); // { email: ["Email already taken"] } // For custom error fields, cast to specific type if (error.status === 422) { const customError = error as unknown as CreateUserError; console.log(customError.email_suggestion); } } }
Error Handling with React Query
import { useMutation } from '@tanstack/react-query'; import type { ErrorOf, ApiError } from '@/api'; function CreateUserForm() { const mutation = useMutation({ mutationFn: (data) => api.users.store(data), onError: (error: ApiError) => { if (error.status === 422 && error.errors) { // Show field-specific errors Object.entries(error.errors).forEach(([field, messages]) => { setFieldError(field, messages[0]); }); } else if (error.status === 401) { redirectToLogin(); } }, }); // ... }
Type Helpers
Extract types from route names for use in your components:
import type { RequestOf, ResponseOf, ParamsOf, QueryOf, ErrorOf } from '@/api'; // Request body type type CreateUserPayload = RequestOf<'users.store'>; // Response type type UserResponse = ResponseOf<'users.show'>; // URL parameters type type UserParams = ParamsOf<'users.show'>; // { user: number } // Query parameters type type UserQuery = QueryOf<'users.index'>; // { page?: number, per_page?: number } // Error type (custom or ValidationError) type StoreUserError = ErrorOf<'users.store'>;
React Query Integration
Enable in config:
'outputs' => [ 'react-query' => true, // Core utilities (queryKey, createQueryOptions) 'queries' => true, // Resource-based query hooks (usersQueries, etc.) ],
Generated Files
| File | Description |
|---|---|
react-query.ts |
Low-level utilities: queryKey, createQueryOptions, createInfiniteQueryOptions, createMutationOptions |
queries.ts |
Resource-based query factories organized by API resource (e.g., usersQueries, postsQueries) |
Resource-Based Queries (queries.ts)
The queries.ts file generates query factories for each API resource, providing:
- Pre-configured
queryOptions()andinfiniteQueryOptions()for each GET endpoint - Type-safe query keys with
keysobject for cache management - Automatic infinite query support for paginated endpoints
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { usersQueries, postsQueries } from '@/api'; // Simple query const { data: user } = useQuery(usersQueries.show({ user: 1 })); // Paginated endpoints automatically use infinite queries const { data, fetchNextPage, hasNextPage } = useInfiniteQuery( usersQueries.index({ query: { per_page: 20 } }) ); // Query keys for cache invalidation const queryClient = useQueryClient(); queryClient.invalidateQueries({ queryKey: usersQueries.keys.all }); // ['users'] queryClient.invalidateQueries({ queryKey: usersQueries.keys.show({ user: 1 }) }); // ['users', 'show', { user: 1 }]
Low-Level Utilities (react-query.ts)
For more control, use the low-level utilities:
import { useQuery, useMutation } from '@tanstack/react-query'; import { queryKey, createQueryOptions, createMutationOptions } from '@/api'; // Create query options manually const { data } = useQuery( createQueryOptions('users.show', { path: { user: 1 }, staleTime: 5000, }) ); // Query keys for cache management const key = queryKey('users.show', { path: { user: 1 } }); // ['users.show', { user: 1 }, undefined] // Mutations const mutation = useMutation(createMutationOptions('users.store')); mutation.mutate({ body: { name: 'John', email: 'john@example.com' } });
Full Example
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { usersQueries, api } from '@/api'; function UserProfile({ userId }: { userId: number }) { const { data, isLoading } = useQuery(usersQueries.show({ user: userId })); const queryClient = useQueryClient(); const updateUser = useMutation({ mutationFn: (data) => api.users.update({ user: userId }, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: usersQueries.keys.show({ user: userId }) }); }, }); if (isLoading) return <div>Loading...</div>; return ( <div> <h1>{data?.name}</h1> <button onClick={() => updateUser.mutate({ name: 'New Name' })}> Update </button> </div> ); }
Infinite Queries for Pagination
Paginated endpoints automatically generate infiniteQueryOptions:
import { useInfiniteQuery } from '@tanstack/react-query'; import { usersQueries } from '@/api'; function UserList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery( usersQueries.index({ query: { per_page: 20 } }) ); return ( <div> {data?.pages.flatMap(page => page.data).map(user => ( <div key={user.id}>{user.name}</div> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : 'Load More'} </button> )} </div> ); }
Inertia.js Integration
import { router } from '@inertiajs/react'; import { route, visit, formAction } from '@/api/inertia'; // Generate type-safe URLs const url = route('users.show', { user: 123 }); // Navigate with type safety visit('users.show', { user: 123 }); visit('users.index', null, { query: { page: 2 } }); // Form actions function CreateUserForm() { return ( <form {...formAction('users.store')}> <input name="name" /> <input name="email" type="email" /> <button type="submit">Create</button> </form> ); }
API Client Configuration
Default Usage
The default api object uses relative URLs and works with Laravel's session/cookie authentication:
import { api } from '@/api'; const users = await api.users.index();
Custom Base URL
Use createApi to point to a different API URL (staging, production, external API):
import { createApi } from '@/api'; // Configure for your environment const api = createApi({ baseUrl: import.meta.env.VITE_API_URL, // Vite // baseUrl: process.env.NEXT_PUBLIC_API_URL, // Next.js headers: { Authorization: `Bearer ${token}`, }, onError: (error) => { if (error.status === 401) { window.location.href = '/login'; } }, }); const users = await api.users.index();
Server-Side Usage (Next.js)
// lib/api.server.ts import { createApi } from '@/api'; export const serverApi = createApi({ baseUrl: process.env.API_URL!, headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, }, }); // app/users/page.tsx import { serverApi } from '@/lib/api.server'; export default async function UsersPage() { const users = await serverApi.users.index(); return <UserList users={users.data} />; }
Command Options
# Generate TypeScript definitions (default) php artisan trpc:generate # Generate Postman collection only php artisan trpc:generate --postman # Generate both TypeScript and Postman collection php artisan trpc:generate --format=all # Custom output directory (overrides config) php artisan trpc:generate --output=resources/js/generated # Override API prefix for route filtering php artisan trpc:generate --api-prefix=api/v2 # Skip running typescript:transform automatically php artisan trpc:generate --skip-typescript-transform # Generate Postman collection with environment file php artisan trpc:generate --postman --postman-env # Overwrite files without confirmation php artisan trpc:generate --force
Command Options Reference
| Option | Description |
|---|---|
--output=PATH |
Override the output directory from config |
--api-prefix=PREFIX |
Override the API route prefix filter |
--skip-typescript-transform |
Skip auto-running typescript:transform |
--postman |
Generate Postman collection only (shorthand for --format=postman) |
--postman-env |
Also generate Postman environment file |
--format=FORMAT |
Output format: typescript (default), postman, or all |
--force |
Overwrite existing files without confirmation |
Customizing Stubs
Publish the stub templates:
php artisan vendor:publish --tag=trpc-stubs
Templates will be copied to resources/views/vendor/trpc/.
Backstory
Every line of code in this package was generated by Claude through Claude Code.
Here's how it worked:
- Skills Discovery — I asked Claude what skills were needed to build this package
- Actor Creation — For each skill area (architecture, TypeScript generation, testing, etc.), I created specialized agents
- Scope Definition — I defined what each agent should build and what quality criteria to meet
- Orchestration — I ran agents (sometimes 16 in parallel), reviewed outputs, and course-corrected
I didn't write the code — I architected, directed, and reviewed it. When things broke, I asked the right questions. When tests failed, I debugged by asking, not coding. The AI implemented; I directed.
The package has 239 passing tests and works. But it was built as an experiment in AI-directed development. Future updates will continue the same way — directed by me, implemented by Claude.
Found a bug? Open an issue. I'll point Claude at it.
License
MIT License. See LICENSE for details.
Credits
- Oybek Daniyarov
- Claude by Anthropic — the AI that wrote the code
- Spatie for laravel-data and typescript-transformer
- Laravel team for Wayfinder, which inspired some of the thinking here
