
Type-Safe Laravel APIs with Spatie Data and TypeScript
Make every Laravel endpoint a typed contract with Spatie Data, then generate matching TypeScript so your Vue front end can never silently drift.
Your API returns a field. Your front end reads a slightly different field. Nobody notices until a customer lands on the one screen where the two disagree. This is among the most common bugs in full-stack applications, and it is entirely preventable.
The fix is to stop treating the boundary between back end and front end as a handshake and start treating it as a contract. In a Laravel and Vue stack, spatie/laravel-data, often called Spatie Data or simply Laravel Data, lets you declare that contract once in PHP and generate the TypeScript version automatically. Change one, the other follows, and a mismatch becomes a failed build instead of a production incident.
The cost of an untyped boundary
Most Laravel APIs move data around as associative arrays. A controller reads the request, a model is mass-assigned, a response is serialized to JSON, and the shape of that JSON exists only in the developer's head. The Vue side then re-describes the same shape in a hand-written TypeScript interface. Two definitions, kept in agreement by discipline alone, drifting apart the moment someone is in a hurry.
The failures are familiar. A renamed column the front end still references. A nullable field the interface swears is always present. A number that arrives as a string because nobody cast it. None of these are caught by a test that asserts a 200 status code, and all of them reach users. The root problem is that the data has no single authoritative definition, so every layer is free to imagine its own.
A data class is the contract
spatie/laravel-data replaces the loose array with a typed object. You declare what an endpoint accepts as a dedicated class, validation included, right where the fields live.
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
#[TypeScript]
final class ProductInputData extends Data
{
public function __construct(
public string $name,
public string $sku,
public int $price, // stored in cents
public ?string $description = null,
public bool $published = false,
) {}
/** @return array<string, array<int, string>> */
public static function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'sku' => ['required', 'string', 'alpha_dash:ascii', 'max:64'],
'price' => ['required', 'integer', 'min:0'],
'description' => ['nullable', 'string', 'max:2000'],
'published' => ['boolean'],
];
}
}
The validation rules travel with the data they describe, not in a separate form request that some endpoints remember and others forget. Type-hint this class on a controller and Laravel builds it from the incoming request and validates it before your method body runs. There is no manual validation call to leave out, because the contract enforces itself.
Thin controllers, typed responses
With the input contract in place, the controller does exactly one thing.
use App\Data\ProductData;
use App\Data\ProductInputData;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
final class StoreProductController
{
public function __invoke(ProductInputData $data): JsonResponse
{
$product = Product::create($data->toArray());
return response()->json(ProductData::from($product), 201);
}
}
ProductInputData arrives already validated, the model is created, and the response is built from a second data class, ProductData, which is the outbound contract.
#[TypeScript]
final class ProductData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $sku,
public int $price,
public ?string $description,
public bool $published,
) {}
}
ProductData::from($product) maps the model onto the contract by matching property names. A Vue client now receives exactly the shape ProductData describes, on every response, with no hand-maintained serializer in between.
One source of truth, two languages
Here is where the pattern earns its place on the front end. Add the #[TypeScript] attribute to a data class and spatie/laravel-typescript-transformer turns it into a TypeScript definition during a build step.
// resources/types/generated.d.ts (generated, do not edit by hand)
export type ProductData = {
id: number
name: string
sku: string
price: number
description: string | null
published: boolean
}
A single command keeps that file current:
php artisan typescript:transform
The Vue app imports ProductData from the generated file like any other type. The PHP class is the source; the TypeScript type is the output. Rename price in PHP, regenerate, and every Vue component that read the old field fails to compile on your machine. The mismatch that used to ship to production is now a red build locally, which is the cheapest possible place to catch it. Wire typescript:transform into your continuous integration and a drifting type cannot even merge.
Collections and nesting
Single objects are the easy case. Real endpoints return lists and nested structures, and the contract holds there too.
final class ListProductsController
{
public function __invoke(): JsonResponse
{
$products = Product::query()->where('published', true)->latest()->get();
return response()->json(ProductData::collect($products));
}
}
ProductData::collect() returns a typed array that the transformer renders as ProductData[] on the Vue side. Data classes also nest inside one another, so a ProductData can carry a CategoryData, and the generated TypeScript mirrors that nesting exactly. The contract is recursive, which means even deeply shaped responses stay honest.
Beyond the basics: serialization and shaping responses
The examples so far kept the data flat on purpose. Real payloads are messier, and this is where spatie/laravel-data stops being a nicer array and becomes a genuine serialization layer.
Casting and name mapping
Casts turn raw input into proper types on the way in, and name mappers reconcile the casing mismatch between a PHP back end and a TypeScript front end.
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Casts\DateTimeInterfaceCast;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
final class ProductData extends Data
{
public function __construct(
public string $id,
public string $name,
public ProductStatus $status, // backed enum, cast automatically
#[WithCast(DateTimeInterfaceCast::class)]
public DateTimeImmutable $publishedAt, // published_at on the wire
) {}
}
#[MapName(SnakeCaseMapper::class)] is the answer to the camelCase-versus-snake_case problem I sidestepped earlier with single-word fields. Properties stay camelCase in PHP and TypeScript, while their array and JSON form is snake_case. When input and output need different rules, #[MapInputName] and #[MapOutputName] apply them independently.
Casts do the type work. Dates and backed enums are handled by global casts automatically, and #[WithCast(...)] plugs in your own, turning a raw string into a value object before any business logic sees it. Transformers do the reverse on the way out, so a money amount or a timestamp always serializes in one consistent shape. A backed enum is the contract at its best: cast automatically in PHP, and, when the class is tagged for export, generated as a string-literal union in TypeScript.
Shaping the response with lazy properties
A fixed contract does not have to be a bloated one. Lazy properties are computed only when the caller asks for them, which keeps the default response lean and still fully typed.
use Spatie\LaravelData\Lazy;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Illuminate\Support\Collection;
final class ProductData extends Data
{
public function __construct(
public string $id,
public string $name,
#[DataCollectionOf(ReviewData::class)]
public Lazy|Collection $reviews,
) {}
public static function fromModel(Product $product): self
{
return new self(
$product->id,
$product->name,
Lazy::whenLoaded('reviews', $product, fn () => ReviewData::collect($product->reviews)),
);
}
}
Lazy::whenLoaded ties the property to an eager-loaded relation, so a list endpoint never triggers an N+1 query while a detail endpoint can opt in:
ProductData::from($product); // no reviews key
ProductData::from($product)->include('reviews'); // reviews included
The same mechanism reads the request, so ?include=reviews works without a line of controller code, and ->only(), ->except(), and ->exclude() trim the shape from the other direction. One contract serves the lean list and the rich detail view, and the generated TypeScript still describes both.
Partial updates with Optional
PATCH endpoints carry a subtle requirement: telling a field that was omitted apart from a field set to null. Optional makes the difference explicit.
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Attributes\Validation\Max;
final class UpdateProductData extends Data
{
public function __construct(
#[Max(255)]
public string|Optional $name,
public string|Optional $status,
) {}
}
A property typed string|Optional disappears from toArray() when it was not sent, so $product->update($data->toArray()) writes only what the client actually provided. It is the disciplined cousin of the defaults pitfall in the next section: an explicit "maybe absent" rather than a silent fallback. The example also shows the attribute form of validation, #[Max(255)] living right on the property, which reads well for simple constraints and complements the rules() method from earlier.
The gotcha worth knowing
One sharp edge is worth internalizing before you lean on this. In spatie/laravel-data version 4, validation rules are auto-inferred only for properties that do not have a default value. Give a property a default and the inferred required rule quietly disappears, so a missing field silently becomes the default instead of failing validation.
// Pitfall: the default makes Spatie skip the inferred "required" rule,
// so a missing name silently becomes "" instead of being rejected.
final class RiskyInputData extends Data
{
public function __construct(
public string $name = '',
) {}
}
// Fix: declare rules() explicitly so validation never depends on inference.
final class SafeInputData extends Data
{
public function __construct(
public string $name = '',
) {}
/** @return array<string, array<int, string>> */
public static function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}
The habit that avoids it is simple: declare a rules() method for anything that matters, or keep defaults off the fields that are genuinely required. Explicit rules always win over inference, so the safe pattern is also the most readable one. It is the kind of detail that does not show up until an endpoint quietly accepts bad input in production, which is exactly why it belongs in your team's muscle memory.
What it bought on Submitit
On the Submitit platform we rebuilt, this pattern is the spine of the entire API. Every endpoint, roughly seventy typed contracts, accepts and returns data classes, and the Vue front end consumes the generated types directly. A whole category of front-end-versus-back-end bugs simply cannot occur there, because they fail the build before they reach a branch, let alone a customer. The full story of that rebuild is in the Submitit case study.
It also pairs naturally with the rest of the stack. The decision to keep the contract in PHP rather than hand-writing types on both sides was part of a broader set of stack choices we made for the rewrite. And the generated types are one of the things that makes AI-assisted development safe at speed, because a model writing front-end code cannot invent a field the contract does not allow.
Type the boundary once
Typed boundaries are not a luxury reserved for large teams. spatie/laravel-data makes the contract cheap to declare, and spatie/laravel-typescript-transformer makes the TypeScript free to generate. The payoff is a class of bugs that never reaches a user. Declare your inputs and outputs as data classes, generate the types, run the generator in continuous integration, and let the build catch the drift you used to catch in support tickets.
If you are modernizing a Laravel and Vue application and want this kind of safety wired in from the start, we can help.