Form Requests
The Laravel TypeScript Publisher statically analyzes each FormRequest's rules() method and converts it into a TypeScript interface describing the request payload — so the shape of a useForm() call, an Axios body, or a route's request payload type always matches your actual validation rules.
As mentioned in Installation & Usage, form requests don't need the @tolki/ts runtime package — the output is a plain TypeScript interface (or a Record<string, unknown> type alias for dynamic requests).
How Form Request Types Are Generated
- One
.tsfile is generated perFormRequestclass, at a modular, namespace-derived path (e.g.App\Http\Requests\StorePostRequest→app/http/requests/store-post-request.ts). - Barrel
index.tsfiles re-export everything (export * from './store-post-request') per namespace directory, the same as enums and models. - The analyzer instantiates your
FormRequestwithout a real HTTP request or authenticated user and callsrules()directly — it doesn't run a full validation pass, it just inspects the rule definitions you return. - A fake stub user is bound during analysis so that
Auth::user()->someMethod()calls insiderules()don't throw (the stub's__callreturnsfalsefor any method) — this keeps rules that branch onAuth::user()->isAdmin()-style checks statically analyzable. Code that reads a property directly ($this->user()->id) or otherwise depends on real request state isn't covered by the stub and triggers the dynamic fallback instead.
Anatomy of a Generated Form Request
use AbeTwoThree\LaravelTsPublish\Attributes\TsCasts;
use Illuminate\Foundation\Http\FormRequest;
#[TsCasts([
'tags' => 'string[]',
'rating' => ['type' => 'number | bigint', 'optional' => true],
])]
class StorePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
'published' => ['boolean'],
'rating' => ['nullable', 'numeric'],
'email' => ['required', 'email'],
'tags' => ['array'],
'tags.*' => ['string'],
];
}
}/** @see Workbench\App\Http\Requests\StorePostRequest */
export interface StorePostRequest {
title: string;
body: string;
published?: boolean;
rating?: number | bigint | null;
/** @format email */
email: string;
tags?: string[];
}title/bodyare required (therequiredrule), typedstring(thestringrule).publishedhas norequired/sometimesrule, so it's optional — even thoughbooleanalone says nothing about presence.ratingdemonstrates that a#[TsCasts]override only replaces the type and optionality — thenullablerule's| nullsuffix is still appended by the analyzer afterward, givingnumber | bigint | nullrather than justnumber | bigint.emailpicks up a JSDoc metadata annotation (@format email) from theemailrule.tagsis upgraded from the barearrayrule'sunknown[]tostring[]— first automatically (from the siblingtags.*wildcard rule), then explicitly overridden to the same value by#[TsCasts].tags.*does not get a property of its own. A dot-notation or wildcard rule key describes a value insidetags, so it composes intotagsinstead of being emitted alongside it as a quoted"tags.*"key — see Array & Nested Rules.- The class docblock (none here) would become the interface's JSDoc comment; individual fields don't support their own docblocks since they come from array keys inside
rules(), not separate PHP declarations — the JSDoc metadata annotations fill that role instead.
Rule-to-Type Mapping Reference
Rules are checked in this order — the first match wins:
Rule::file()/Rule::dimensions()(File/Dimensionsobjects) →FileRule::anyOf([...])→ union of each inner rule set's own resolved typeRule::enum(...)→ union of the enum's backing values (respects.only()/.except())Rule::in(...)/ stringin:a,b,c→ union of literal values (quoted or unquoted — see Numericin:literals)- Fluent rule objects:
Rule::date(),Email,Password,StringRule→string;Numeric→number;Rule::array()/Contains/DoesntContain→unknown[];Rule::notIn(...)→string - String rule names (see table below)
- Anything unrecognized →
unknown
Strings
string, alpha, alpha_dash, alpha_num, ascii, current_password, hex_color, json, date, date_equals, date_format, email, url, active_url, uuid, ulid, ip, ipv4, ipv6, mac_address, regex, not_regex → string
Numbers
integer, int, numeric, decimal, digits, digits_between → number
Booleans
boolean, accepted, accepted_if, declined, declined_if → boolean
Files
file, image, mimes, mimetypes, extensions → File
Arrays
array, list → unknown[] (upgraded to T[] automatically when a sibling field.* wildcard rule resolves to type T — see Array & Nested Rules; upgraded to a keyed object instead when required_array_keys/in_array_keys/array:/array_keys: names its keys — see Key-list rules)
Numeric in: literals
Rule::in([1, 2, 3]) carries real integers, so it emits 1 | 2 | 3. The string form can't: Laravel's own ValidationRuleParser::parse() hands in:1,2,3 over as strings, so 'legacy_code' => ['string', 'in:1,2,3'] emits the quoted '1' | '2' | '3'.
A sibling rule declaring the field numeric is the signal that flips it to unquoted, and that list is exactly the Numbers list above — integer, int, numeric, decimal, digits, digits_between:
'priority_level' => ['required', 'integer', 'in:1,2,3'], // 1 | 2 | 3
'digit_grade' => ['digits:1', 'in:1,2,3'], // 1 | 2 | 3
'decimal_tier' => ['decimal:1', 'in:1.5,2.5'], // 1.5 | 2.5
'legacy_code' => ['required', 'string', 'in:1,2,3'], // '1' | '2' | '3'decimal, digits and digits_between now count as numeric
Previously only integer, int and numeric triggered the unquoted form, so ['digits:1', 'in:1,2,3'] typed as number from its own rule while still emitting '1' | '2' | '3' for the union — a field that could never satisfy both. One list now backs both checks.
What to do: nothing, unless you were comparing one of these fields against a quoted string literal. TypeScript will point at every site that needs === 1 instead of === '1'.
Coercion is deliberately conservative even on a numeric field: a value only loses its quotes when it round-trips back to identical text. Laravel's validateIn() compares the raw string against the literal param, so a padded or reformatted value has to stay a string — emitting the normalized number would describe a value Laravel itself rejects.
'padded_numeric_code' => ['required', 'numeric', 'in:007,2.50'], // '007' | '2.50'
'padded_decimal_tier' => ['decimal:2', 'in:1.50,2.50'], // '1.50' | '2.50'Presence, Nullability & Exclusion
| Rule | Effect |
|---|---|
required (or any rule starting with required, including Rule::requiredIf()/requiredUnless()) | Field is required (no ?) |
sometimes | Field is optional, even combined with required |
nullable | Adds | null to the field's type |
missing / prohibited | Field is excluded from the interface entirely — not just marked optional. A nested key is dropped from its parent's shape instead; see Array & Nested Rules |
public function rules(): array
{
return [
'title' => ['required', 'string'], // title: string;
'slug' => ['sometimes', 'required', 'string'], // slug?: string;
'notes' => ['nullable', 'string'], // notes?: string | null;
'internal_id' => ['prohibited'], // omitted entirely
];
}TIP
Fields with no required/sometimes rule at all (e.g. a bare 'published' => ['boolean']) are optional by default — presence must be declared explicitly, just like in Laravel's own validation.
Array & Nested Rules
Dot-notation (meta.description) and wildcard (tags.*) rule keys describe constraints on nested values, not top-level JSON keys you'd set directly. They compose into their nearest undotted ancestor and are never emitted as their own quoted property — "order.id"?: string would suggest you could send a literal order.id key, which Laravel's dot-notation validation never means:
class ArrayRulesRequest extends FormRequest
{
public function rules(): array
{
return [
'tags' => ['array', 'min:1', 'max:10'],
'tags.*' => ['required', 'string', 'max:50'],
'selected_ids' => ['required', 'array', 'between:1,5'],
'selected_ids.*' => ['required', 'integer'],
'order' => ['required', 'array'],
'order.id' => ['required', 'uuid'],
'order.items' => ['required', 'array'],
'order.items.*.product_id' => ['required', 'integer'],
];
}
}export interface ArrayRulesRequest {
tags?: string[];
selected_ids: number[];
/** @format uuid order.id */
order: { id: string; items: { product_id: number }[] };
}Nesting is unbounded — order.items.*.product_id composes through every one of its segments the same way tags.* composes through its one. The parent's own presence and nullability rules decide the parent's ? and | null (selected_ids is required because its own rule says required), while each nested key's own rules decide its optionality inside the composed shape.
Nested edge cases
| Rules | Generated type | Why |
|---|---|---|
'choices' => ['nullable','array']'choices.*' => ['nullable','string'] | choices?: (string | null)[] | null | The element's nullable folds into the element type; the array's own nullable stays on the array. |
'options' => ['array']'options.*' => ['string']'options.default' => ['string'] | options?: { default?: string } & Record<string, string> | A wildcard beside a named sibling is a map with some pinned keys — emitted as an intersection, which stays valid TypeScript even when the two halves differ. |
'meta' => ['array']'meta.secret' => ['prohibited'] | meta?: Record<string, never> | Every named key is prohibited, so no key is allowed — not an empty object you may add keys to. |
'empties' => ['array']'empties.*' => ['prohibited'] | empties?: never[] | The element may never appear, so the array may never hold anything. |
'v1\.0' => ['required','string'] | "v1.0": string | An escaped dot is a literal character in the attribute name, so it stays one field — quoted, since . isn't a bare identifier. |
'items' => ['array']'items.0.name' => ['required','string'] | items?: { name: string }[] | Explicit numeric indices describe a list. { "0": … } is a type no real JSON array is assignable to. |
'variants.0.name' => [...]'variants.1.email' => [...] | variants?: ({ name: string } | { email: string })[] | Indices with different shapes union, parenthesized so [] applies to the whole union rather than the last member. |
A prohibited/missing rule on a nested key drops that key from its parent's shape, and drops its own descendants with it: 'order.secret' => ['prohibited'] alongside 'order.secret.token' => ['required','uuid'] leaves nothing of secret in order.
Key-list rules: known keys without a full shape
Four validation rules describe an array's keys without declaring a full nested shape for them. Each declared key becomes a synthesized unknown-typed property instead of the array collapsing to unknown[] — this is the fix for a config field that used to come out unknown[] even though in_array_keys:timezone tells you exactly which key to expect. The rules differ in whether Laravel's validator actually guarantees the key is present, and the emitted ? follows that:
| Rule | Meaning | PHP | TypeScript |
|---|---|---|---|
required_array_keys:a,b | all listed keys must be present | 'permissions' => ['required','array','required_array_keys:read,write'] | permissions: { read: unknown; write: unknown }; |
in_array_keys:a,b | at least one listed key must be present — no single key is guaranteed | 'config' => ['required','array','in_array_keys:timezone'] | config: { timezone?: unknown }; |
array:a,b | restricts which keys are allowed; says nothing about presence | 'preferences' => ['nullable','array:theme,locale'] | preferences?: { theme?: unknown; locale?: unknown } | null; |
array_keys:a,b | restricts which keys are allowed; requires ≥1 listed key; presence of any given key unenforced | 'attributes_map' => ['required','array_keys:color,size'] | attributes_map: { color?: unknown; size?: unknown }; |
A field can combine a key-list rule with a real declared child, and the two merge instead of the synthesized keys being dropped. A real child wins the type and optionality on a name collision; any key with no real child stays synthesized:
'shipping' => ['required', 'array', 'required_array_keys:method,address'],
'shipping.method' => ['nullable', 'in:standard,express'],shipping: { method?: 'standard' | 'express' | null; address: unknown };method keeps its own declared type and optionality from 'shipping.method' even though required_array_keys also named it as required; address has no declared rule of its own, so it stays the synthesized unknown, required because required_array_keys said so.
JSDoc Metadata Annotations
Certain rules attach a JSDoc comment above the field instead of (or in addition to) affecting its type:
| Rule(s) | Annotation |
|---|---|
email, url, active_url, uuid, ulid, ip, ipv4, ipv6, mac_address, hex_color | @format {rule} |
date, date_equals | @format date |
exists:table,column / Rule::exists(...) | @constraint exists |
unique:table,column / Rule::unique(...) | @constraint unique |
required_if, required_unless, required_with, required_without, required_with_all, required_without_all | @metadata required-conditionally |
Rule::requiredIf(...) / Rule::requiredUnless(...) | @metadata required-if conditional |
Rule::prohibitedIf(...) / Rule::prohibitedUnless(...) | @metadata prohibited-if conditional |
Rule::excludeIf(...) / Rule::excludeUnless(...) | @metadata exclude-if conditional |
not_in:a,b,c | @not a, b, c |
'category_id' => ['required', 'integer', 'exists:categories,id'],/** @constraint exists */
category_id: number;An annotation on a nested rule isn't lost when that rule composes into its parent — it's hoisted onto the parent's comment block and suffixed with the full rule key it came from, wildcards included, so you can tell which nested key it describes:
'order.id' => ['required', 'uuid'],
'products.*.contact_email' => ['required', 'email'],/** @format uuid order.id */
order: {
id: string; /* … */
}
/** @format email products.*.contact_email */
products: {
contact_email: string; /* … */
}
[];The one exception is a prohibited nested key: since it and its descendants are dropped from the type, their annotations are dropped too.
#[TsCasts] — Overriding Field Types
Same attribute (and array shape) used by models and resources — place it on the FormRequest class to override a field's inferred type, mark it optional, or add a field with a custom imported type:
use AbeTwoThree\LaravelTsPublish\Attributes\TsCasts;
use Illuminate\Validation\Rule;
#[TsCasts([
'status' => "'draft' | 'published' | 'archived'",
'attributes' => ['type' => 'PostAttributes', 'import' => '@js/types/posts'],
])]
class UpdatePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['sometimes', 'string', 'max:255'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'attributes' => ['sometimes', 'array'],
];
}
}import type { PostAttributes } from "@js/types/posts";
export interface UpdatePostRequest {
title?: string;
status: "draft" | "published" | "archived";
attributes?: PostAttributes;
}WARNING
#[TsCasts] only replaces the field's type and optionality (via the optional key) — it does not clear a nullable rule already on that field. If the underlying rule includes nullable, the override's type still gets | null appended, same as in the Anatomy example above.
WARNING
#[TsCasts] keys match generated field names, and a dot-notation rule key never becomes one — it composes into its parent. A key like 'order.id' or 'tags.*' matches nothing and is silently ignored; it does not add a field either. Override the parent ('order', 'tags') to replace the whole shape, or make the rule itself precise enough not to need an override. The only dotted key that matches is one whose dot was escaped in the rule ('v1\.0' generates the field "v1.0", so 'v1.0' overrides it).
#[TsExtends]
FormRequest classes support #[TsExtends] and the ts_extends.form_requests config array, the same generic interface-extension mechanism used everywhere else in this package. See Extending Interfaces for the full attribute reference and config syntax.
Dynamic Requests
When rules() can't be called without real HTTP/session/auth state — for example, reading a property directly off the authenticated user instead of calling a method — the analyzer can't resolve it statically, and the whole class falls back to a Record<string, unknown> type alias:
class DynamicRequest extends FormRequest
{
public function rules(): array
{
$userId = $this->user()->id; // throws in the stubbed analysis context
return [
'name' => ['required', 'string'],
'user_id' => ['required', 'integer', 'in:'.$userId],
];
}
}/**
* @see Workbench\App\Http\Requests\DynamicRequest
* @dynamic Rules could not be resolved statically.
*/
export type DynamicRequest = Record<string, unknown>;TIP
Method calls like Auth::user()->isAdmin() are safe — the analyzer stubs an authenticated user whose methods all return false. It's direct property access or anything else that needs real request/session data that triggers the fallback.
Filtering & Excluding Form Requests
Same include/exclude pattern used by enums, models, and resources:
// config/ts-publish.php
'form_requests' => [
'included' => [App\Http\Requests\StorePostRequest::class], // only these (empty = all)
'excluded' => [App\Http\Requests\InternalRequest::class], // never publish these
'additional_directories' => ['modules/Blog/Http/Requests'],
],#[TsExclude] on the class excludes the whole request:
use AbeTwoThree\LaravelTsPublish\Attributes\TsExclude;
#[TsExclude]
class InternalRequest extends FormRequest
{
// Not published to TypeScript
}See Excluding Content for the full attribute behavior shared across models, enums, resources, and routes. There's no field-level equivalent for form requests — rules live in a single rules() array rather than individual methods, so exclusion is class-only.
Route Integration
When a controller action type-hints a FormRequest, its generated interface is automatically attached to that action's route export via annotateRequestPayload<T>() — no configuration needed. See Form Request Payload Types in the Routing docs for the full annotateRequestPayload / InferRequestPayload reference.
Configuration Reference
The full list of form_requests.* config keys — including pipeline class overrides for advanced customization — lives in the Configuration Reference.