Building Fully Type-Safe APIs With Laravel
When doing anything, I am a firm believer in choosing the best tool for the job. And for building web apps, it means using Laravel as the backend and a JavaScript frontend.
Of course, there are other solutions. If you can pull off Inertia or Livewire - that’s awesome. I love both of these projects and use them, but the reality of the situation is that there are a lot of JavaScript frontends out there.
However, using two different languages with a large chasm between them introduces a lot of pain, and maintaining some semblance of sanity across the boundary is notoriously difficult. At the core of it is the fact that your frontend (and sometimes even your teammates) knows nothing about your own API, routes, or types. Types drift apart, URLs change silently, and bugs slip through despite your best efforts.
Why Not a JavaScript Backend?
Now, reasonable question: why not build the backend in JavaScript? It would eliminate at least some of the problems, right? Well, the reality of the situation is that writing backend in JavaScript sucks is like assembling a Frankenstein monster. There aren’t really a lot of unified solutions for a complete backend, new (and hopefully better?) solutions are invented constantly, and IMO it will introduce more problems than it solves.
That being said, I was looking at tRPC for many months, hoping something like that would appear in the Laravel world. Who wouldn’t want to combine great DX of type safety with the robustness of the Laravel ecosystem? So while we are waiting for the full release of Wayfinder, let’s build “tRPC for Laravel” ourselves and pair it with a Next.js frontend (though any TypeScript frontend would work).
The Beginning: Writing Types Manually
Let’s start with a typical setup. We have a Laravel API with Sanctum authentication and a Next.js frontend with React Query. Here’s what our authentication hook looks like initially:
// lib/useAuth.ts
interface User {
id: number;
name: string;
email: string;
}
export function useAuth({ middleware }: { middleware?: "guest" | "auth" } = {}) {
const router = useRouter();
const queryClient = useQueryClient();
const { data: user, isLoading: loading, error } = useQuery({
queryKey: ["user"],
queryFn: async () => {
const { data } = await api.get<User>("/api/auth/user");
return data;
},
retry: false,
});
const loginMutation = useMutation({
mutationFn: (credentials: { email: string; password: string }) =>
api.post("/api/auth/login", credentials),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user"] });
router.push("/dashboard");
},
});
// ...
}
And our login page:
// pages/login.tsx
interface LoginForm {
email: string;
password: string;
}
export default function LoginPage() {
const { loginMutation } = useAuth({ middleware: "guest" });
const { register, handleSubmit } = useForm<LoginForm>();
const onSubmit = (data: LoginForm) => {
loginMutation.mutate({ email: data.email, password: data.password });
};
// ...
}
This works, but there are problems. The biggest problem is that we are basically just gaslighting ourselves: we are creating types manually and don’t actually know if our endpoints will receive or respond with these exact data types. And while everything looks good and TypeScript doesn’t show any errors, one small misstep and we will be faced with numerous client errors which are not that easy to debug. So what should we do?
Glimmers of Hope: Introducing Data Classes
Let’s start by actually trying to structure our data on the backend, right now it’s kind of meh. Thankfully, Laravel has built-in solutions for structuring input/output for HTTP requests: FormRequest and JsonResource. And these are nice and would probably work for our purposes, but I prefer a different solution - DTOs, and specifically spatie/laravel-data package, which pairs nicely with spatie/laravel-typescript-transformer.
Laravel Data gives us typed DTOs that can be used for both request validation and response serialization. The TypeScript Transformer generates TypeScript types from these PHP classes.
// app/Data/LoginData.php
<?php
namespace App\Data;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
class LoginData extends Data
{
public function __construct(
public string $email,
public string $password,
) {}
}
// app/Data/UserData.php
<?php
namespace App\Data;
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,
) {}
}
Running
php artisan typescript:transform —format
generates:
// types/generated.d.ts
declare namespace App.Data {
export type LoginData = {
email: string;
password: string;
};
export type UserData = {
id: number;
name: string;
email: string;
};
}
Now our frontend can import these types instead of defining them manually. If someone adds a remember_me field to LoginData, the TypeScript type updates automatically.
Will It Work for Something More Complex?
Well, let’s test it by adding basic CRUD (yes, we are skipping ToDo lists) and allowing users to list, create, update, and delete posts.
// app/Data/PostFormData.php
<?php
namespace App\Data;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
class PostFormData extends Data
{
public function __construct(
public string $title,
public string $body,
) {}
}
// app/Data/PostData.php
<?php
namespace App\Data;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
class PostData extends Data
{
public function __construct(
public int $id,
public PostFormData $formData,
public string $created_at,
) {}
}
Note how PostData contains PostFormData as a nested property, which is preserved in the generated TypeScript. The controller uses these types for both input and output:
// app/Http/Controllers/PostController.php
class PostController extends Controller
{
public function index(Request $request): array
{
$posts = $request->user()->posts()->latest()->get();
return PostData::collect($posts)->toArray();
}
public function store(PostFormData $data, Request $request): PostData
{
$post = $request->user()->posts()->create($data->toArray());
return PostData::from($post);
}
public function update(PostFormData $data, Post $post, Request $request): PostData
{
$post->update($data->toArray());
return PostData::from($post);
}
}
The frontend hook now uses generated types:
// lib/usePosts.ts
import api from "./axios";
export function usePosts() {
const queryClient = useQueryClient();
const postsQuery = useQuery({
queryKey: ["posts"],
queryFn: async () => {
const { data } = await api.get<App.Data.PostData[]>("/api/posts");
return data;
},
});
const createMutation = useMutation({
mutationFn: (data: App.Data.PostFormData) =>
api.post("/api/posts", data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: App.Data.PostFormData }) =>
api.put(`/api/posts/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});
// ...
}
This is already so much better! Now when we change our backend data, it will automatically change TypeScript definitions. We have worked like this on a pretty complex app with hundreds of models, routes, and generated types - and it works just fine. Begone, days of manually writing types!
So are we done? Not really, and there is a problem - we are still gaslighting ourselves.
Are we really sure that the backend route will actually receive or respond with this specific form of data? Do we know that the URL will actually work? Or does it even exist?
See, we are doing the same thing again, although in a slightly nicer way: we are defining the exact same thing two times, in two different programming languages, manually. This can and will lead to problems.
So Is There an Actual Solution?
Well, there isn’t really a way to avoid writing the same thing in two programming languages. The only part we can change is “manually”. What if a machine can do it for us?
Here’s where things get interesting. Luckily, Spatie left us a way to write our own TypeScript transformers and hook into their internals. We can write a custom transformer that generates TypeScript not just for data classes, but for entire controllers, including their routes, HTTP methods, request types, and response types.
// app/Transformers/ControllerTransformer.php
<?php
namespace App\Transformers;
use App\Http\Controllers\Controller;
use Spatie\TypeScriptTransformer\Transformers\Transformer;
use Spatie\TypeScriptTransformer\Transformers\TransformsTypes;
class ControllerTransformer implements Transformer
{
use TransformsTypes;
public function transform(ReflectionClass $class, string $name): ?RuntimeObjectTransformedType
{
if (!$class->isSubclassOf(Controller::class)) {
return null;
}
$endpoints = [];
foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
$route = $this->getRouteForMethod($class->getName(), $method->getName());
if (!$route) continue;
$uri = $this->parseUri($route->uri());
$endpoints[] = [
'name' => $method->getName(),
'uri' => $uri['path'],
'method' => $route->methods()[0],
'parameters' => $uri['params'],
'dataType' => $this->getDataType($method, $missingSymbols),
'responseType' => $this->getResponseType($method, $missingSymbols),
];
}
return RuntimeObjectTransformedType::create(
class: $class,
name: $name,
transformed: $this->buildTypeScript($endpoints),
missingSymbols: $missingSymbols,
keyword: 'const',
);
}
}
The transformer inspects each controller method, finds its associated route, extracts parameter types from the method signature, and generates a TypeScript object. The result:
// types/generated.ts
export const PostController = {
index: {
uri: () => "/api/posts",
method: "GET",
response: {} as Array<PostData>,
},
store: {
uri: () => "/api/posts",
method: "POST",
data: {} as PostFormData,
response: {} as PostData,
},
update: {
uri: ({ post }: { post: string | number }) => `/api/posts/${post}`,
method: "PUT",
data: {} as PostFormData,
response: {} as PostData,
},
destroy: {
uri: ({ post }: { post: string | number }) => `/api/posts/${post}`,
method: "DELETE",
},
} as const;
Holy cow! Now we have everything: URLs with typed parameters, HTTP methods, request and response types - everything generated from the actual PHP code.
Hooking This into the Frontend
We can start consuming these objects directly, but the resulting DX will not be pretty. Well, let’s summon some TypeScript magic:
// lib/makeController.ts
export type Route<P = any, D = any, R = any> = {
uri: (params: P) => string;
method: string;
data?: D;
response?: R;
};
type RouteParams<T extends Route> = T extends { uri: (params: infer P) => string } ? P : never;
type RouteData<T extends Route> = T extends { data: infer D } ? D : undefined;
type RouteResponse<T extends Route> = T extends { response: infer R } ? R : unknown;
export type Controller<T extends Record<string, Route>> = {
[K in keyof T]: (
params: RouteParams<T[K]>,
data?: RouteData<T[K]>,
config?: AxiosRequestConfig
) => Promise<RouteResponse<T[K]>>;
};
export const makeController = <T extends Record<string, Route>>(endpoints: T): Controller<T> => {
const controller = {} as Controller<T>;
for (const key in endpoints) {
const endpoint = endpoints[key];
controller[key] = async (params, data, config) => {
const response = await api.request({
url: endpoint.uri(params),
method: endpoint.method,
data,
...config,
});
return response.data;
};
}
return controller;
};
Okay, I know this isn’t pretty and is hard to understand. This code is the result of many days of work, though Claude can probably one-shot something like this these days. Well, the good news is we don’t need to think about this code ever again, and definitely there will be no need for maintenance or changes later (haha).
But look at our beautiful hooks now and how simple our API requests are:
// lib/usePosts.ts
import { makeController } from "@/lib/makeController";
import { PostController, PostFormData } from "@/types/generated";
export function usePosts() {
const queryClient = useQueryClient();
const Controller = makeController(PostController);
const postsQuery = useQuery({
queryKey: ["posts"],
queryFn: async () => Controller.index({}),
});
const createMutation = useMutation({
mutationFn: (data: PostFormData) => Controller.store({}, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: PostFormData }) =>
Controller.update({ post: id }, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => Controller.destroy({ post: id }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});
return { posts: postsQuery.data ?? [], isLoading: postsQuery.isLoading, /* ... */ };
}
This is almost like calling PHP methods directly from TypeScript. The types flow through completely:
Controller.index({})returnsPromise<Array<PostData>>Controller.store({}, data)requiresPostFormDataand returnsPromise<PostData>Controller.update({ post: id }, data)requires thepostparameter andPostFormDataController.destroy({ post: id })only requires thepostparameter
If the backend changes, all TypeScript definitions will change automatically, including URLs, HTTP verbs, request and response types.
And now even if we forget to change something on the frontend, like updating a form to new type definitions or views using wrong properties - our frontend project would just fail at the CI pipeline and we will be forced to fix the bug.
Icing on the Cake
For the finishing touch, let’s add toast notifications. Instead of returning generic JSON with success/fail messages, we can return a typed ToastData object:
// app/Enums/ToastType.php
<?php
namespace App\Enums;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
enum ToastType: string
{
case Success = 'success';
case Error = 'error';
case Warning = 'warning';
case Info = 'info';
}
// app/Data/ToastData.php
<?php
namespace App\Data;
use App\Enums\ToastType;
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
class ToastData extends Data
{
public function __construct(
public string $message,
public ToastType $type = ToastType::Success,
) {}
}
The generated TypeScript:
export type ToastType = "success" | "error" | "warning" | "info";
export type ToastData = {
message: string;
type: ToastType;
};
Now our controller can return toast data:
public function destroy(Post $post, Request $request): ToastData
{
$post->delete();
return new ToastData('Post deleted');
}
const deleteMutation = useMutation({
mutationFn: (id: number) => Controller.destroy({ post: id }),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
toast[response.type](response.message); // Fully typed!
},
});
As you can see, the TypeScript transformer works really well with PHP backed enums.
The Final Result
Here’s what our generated types look like at the end:
export const AuthController = {
login: {
uri: () => "/api/auth/login",
method: "POST",
data: {} as LoginData,
response: {} as ToastData,
},
logout: {
uri: () => "/api/auth/logout",
method: "POST",
response: {} as ToastData,
},
user: {
uri: () => "/api/auth/user",
method: "GET",
response: {} as UserData,
},
} as const;
export type LoginData = {
email: string;
password: string;
};
export const PostController = {
index: {
uri: () => "/api/posts",
method: "GET",
response: {} as Array<PostData>,
},
store: {
uri: () => "/api/posts",
method: "POST",
data: {} as PostFormData,
response: {} as ToastData,
},
update: {
uri: ({ post }: { post: string | number }) => `/api/posts/${post}`,
method: "PUT",
data: {} as PostFormData,
response: {} as ToastData,
},
destroy: {
uri: ({ post }: { post: string | number }) => `/api/posts/${post}`,
method: "DELETE",
response: {} as ToastData,
},
} as const;
export type PostData = {
id: number;
formData: PostFormData;
created_at: string;
};
export type PostFormData = {
title: string;
body: string;
};
export type ToastData = {
message: string;
type: ToastType;
};
export type ToastType = "success" | "error" | "warning" | "info";
export type UserData = {
id: number;
name: string;
email: string;
};
Every piece of information the frontend needs to make an API call is generated from the backend:
Route URLs with typed parameters
HTTP methods
Request body types
Response types
Even enum values for things like toast types
And we can automate it by introducing a Vite plugin for automatically generating TypeScript definitions when our PHP files change (thank you, Claude, for one-shotting this one):
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import { exec } from 'child_process';
function typescriptTransformer() {
return {
name: 'typescript-transformer',
buildStart() {
exec('php artisan typescript:transform --format', (error, stdout) => {
if (error) {
console.error('TypeScript transform failed:', error);
return;
}
console.log(stdout);
});
},
handleHotUpdate({ file }) {
if (file.includes('/app/') && file.endsWith('.php')) {
exec('php artisan typescript:transform --format', (error, stdout) => {
if (error) {
console.error('TypeScript transform failed:', error);
return;
}
console.log(stdout);
});
}
},
};
}
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
tailwindcss(),
typescriptTransformer(),
],
server: {
watch: {
ignored: ['**/storage/framework/views/**'],
},
},
});
What We Gained
Single source of truth: Types are defined once in PHP and generated for TypeScript
Compile-time safety: Breaking changes cause TypeScript errors, not runtime failures
Refactoring confidence: Rename a route, change a parameter, modify a response—the compiler tells you everywhere that needs updating
Self-documenting API: The generated types serve as accurate, always-up-to-date documentation
IDE support: Full autocomplete for controller methods, parameters, and return types
No more magic strings: URLs are generated functions, not hardcoded strings
Final Thoughts
All code is available on Github
Are there other solutions?
Well, OpenAPI exists and maybe it’s better? But every time I looked at the docs, I died inside a little bit and never actually tried it.
Another possible solution would be Wayfinder by the Laravel team. It seems like there are still some months before the full release, and I am waiting for it with hopes of deleting all the code I showed you today and replacing it with code written by people who actually know what they are doing.
Also, huge shoutout to Spatie. Your contributions to the community are hard to measure, and I will never be able to repay you.
Another huge shoutout to Claude Code. Thank you for helping me vibe code this demo. Without your help I would never write this article.
