Skip to content

Documenting Form Requests ​


How it works ​

Luminous finds your FormRequest from the method type hint and reads its rules() automatically. You do not have to write your validation rules twice.

php
class CreatePaymentRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'amount'   => ['required', 'integer', 'min:1'],
            'currency' => ['required', Rule::in(['USD', 'EUR', 'KES'])],
        ];
    }
}

Luminous reads this and produces:

json
{
  "type": "object",
  "properties": {
    "amount":   { "type": "integer", "minimum": 1 },
    "currency": { "type": "string",  "enum": ["USD", "EUR", "KES"] }
  },
  "required": ["amount", "currency"]
}

That is all you need to do.


What Luminous extracts automatically ​

Types ​

RuleSchema type
stringstring
integer / intinteger
numeric / decimalnumber
boolean / boolboolean
arrayarray
file / imagestring, format: binary

Formats ​

RuleFormat
emailemail
uuiduuid
urluri
datedate
ip / ipv4ipv4
ipv6ipv6

Constraints ​

RuleWhat it produces
min:N on integer or numberminimum: N
max:N on integer or numbermaximum: N
min:N on stringminLength: N
max:N on stringmaxLength: N
min:N on arrayminItems: N
max:N on arraymaxItems: N
between:M,Nminimum/maximum or minLength/maxLength depending on type
size:N on stringminLength: N, maxLength: N
digits:NminLength: N, maxLength: N, pattern: ^\d{N}$
in:a,b,cenum: ["a","b","c"]
Rule::in([...])enum: [...]
Rule::enum(MyEnum::class)enum: [all case values]
regex:/pattern/pattern (delimiters stripped)
nullabletype becomes ["<field_type>", "null"] (e.g. ["integer", "null"] for an integer field)
sometimesfield is removed from required
required_if:field,valuefield is optional, description note added

Rules that Luminous does not recognise (like Rule::exists(), Rule::unique(), Rule::prohibits(), and custom rule objects) are silently ignored. They have no effect on the generated schema.


Required vs optional fields ​

A field is required in the schema when its rules include 'required' and do not include 'sometimes'.

php
'amount'      => ['required', 'integer'],              // required
'coupon_code' => ['sometimes', 'nullable', 'string'],  // NOT required (key can be absent)
'notes'       => ['nullable', 'string'],               // required (key must be present, value can be null)

The difference between nullable and sometimes trips up a lot of developers:

  • nullable means the field must be in the request body, but its value can be null
  • sometimes means the field can be left out of the request body entirely

Luminous reflects this distinction accurately in the spec.


Adding descriptions and examples with hints() ​

rules() cannot express what a field means or show a good example value. That is what hints() is for. Most FormRequests do not need it. Add it only when a developer reading the spec would not understand a field without extra context.

php
use Botnetdobbs\Luminous\Support\Shape;

class CreatePaymentRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'amount'      => ['required', 'integer', 'min:1'],
            'currency'    => ['required', Rule::in(['USD', 'EUR', 'KES'])],
            'description' => ['required', 'string', 'max:500'],
            'metadata'    => ['sometimes', 'array'],
        ];
    }

    public function hints(): array
    {
        return [
            'amount'   => Shape::integer()->description('Amount in minor units. 10000 = $100.00')->example(10000),
            'currency' => Shape::string()->description('ISO 4217 currency code'),
            'metadata' => Shape::object([])->description('Arbitrary key-value pairs attached to this payment'),
        ];
    }
}

hints() is additive only. It can add a description and an example. It cannot change a type, remove a required field, or override a constraint. rules() owns the schema contract.

Skip hints() when field names are obvious and constraints are self-explanatory. Add it when a new developer would genuinely need the extra context.


Nested objects ​

Dot-notation rules become nested object schemas automatically. No extra work.

php
public function rules(): array
{
    return [
        'billing_address'          => ['required', 'array'],
        'billing_address.street'   => ['required', 'string', 'max:255'],
        'billing_address.city'     => ['required', 'string', 'max:100'],
        'billing_address.country'  => ['required', 'string', 'size:2'],
        'billing_address.postcode' => ['sometimes', 'string', 'max:20'],
    ];
}

Luminous produces:

json
{
  "billing_address": {
    "type": "object",
    "properties": {
      "street":   { "type": "string", "maxLength": 255 },
      "city":     { "type": "string", "maxLength": 100 },
      "country":  { "type": "string", "minLength": 2, "maxLength": 2 },
      "postcode": { "type": "string", "maxLength": 20 }
    },
    "required": ["street", "city", "country"]
  }
}

postcode is not required because it uses sometimes.


Arrays of objects ​

Wildcard .* notation becomes a typed array of objects. No extra work.

php
public function rules(): array
{
    return [
        'items'              => ['required', 'array', 'min:1'],
        'items.*.product_id' => ['required', 'uuid'],
        'items.*.quantity'   => ['required', 'integer', 'min:1', 'max:999'],
        'items.*.note'       => ['sometimes', 'nullable', 'string', 'max:200'],
    ];
}

Luminous produces:

json
{
  "items": {
    "type": "array",
    "minItems": 1,
    "items": {
      "type": "object",
      "properties": {
        "product_id": { "type": "string", "format": "uuid" },
        "quantity":   { "type": "integer", "minimum": 1, "maximum": 999 },
        "note":       { "type": ["string", "null"], "maxLength": 200 }
      },
      "required": ["product_id", "quantity"]
    }
  }
}

Arrays of scalar values ​

php
'tag_ids'   => ['required', 'array'],
'tag_ids.*' => ['required', 'uuid'],

Produces:

json
{
  "tag_ids": {
    "type": "array",
    "items": { "type": "string", "format": "uuid" }
  }
}

Password confirmation ​

When a field has the confirmed rule, Luminous automatically adds the _confirmation companion field to the schema so consumers know they need to send both.

php
'password' => ['required', 'string', 'min:8', 'confirmed'],

Produces both fields:

json
{
  "password": {
    "type": "string",
    "format": "password",
    "minLength": 8,
    "writeOnly": true
  },
  "password_confirmation": {
    "type": "string",
    "minLength": 8,
    "writeOnly": true,
    "description": "Must match the password field"
  }
}

Both are writeOnly because they go into requests but never appear in responses.


File uploads ​

Any field with a file, image, mimes, or mimetypes rule causes Luminous to set the request body media type to multipart/form-data automatically.

php
public function rules(): array
{
    return [
        'avatar'     => ['required', 'image', 'mimes:jpg,png', 'max:2048'],
        'first_name' => ['required', 'string'],
    ];
}

The request body is documented as multipart/form-data. The avatar field gets format: binary. You do not need to tell Luminous this is a file upload.


Conditional required fields ​

required_if, required_unless, and similar rules cannot be expressed as static OpenAPI constraints. Luminous marks the field as optional and adds a description note explaining the condition.

php
'company_name' => ['required_if:account_type,business', 'string', 'max:255'],

Produces:

json
{
  "company_name": {
    "type": "string",
    "maxLength": 255,
    "description": "Required when account_type is 'business'."
  }
}

If you add a hints() entry for this field with your own description, your description replaces the auto-generated one.


Using #[ApiShape] to bypass rules() entirely ​

If your rules() depends on the authenticated user or injected services, Luminous cannot call it safely. In that case, put #[ApiShape] on the FormRequest class and write a schema() method that returns the schema directly. Luminous uses that instead of touching rules().

php
use Botnetdobbs\Luminous\Attributes\ApiShape;
use Botnetdobbs\Luminous\Support\Shape;

#[ApiShape]
class UpdateProfileRequest extends FormRequest
{
    public static function schema(): Shape
    {
        return Shape::object([
            'name'  => Shape::string()->maxLength(255),
            'email' => Shape::email(),
            'bio'   => Shape::string()->maxLength(1000)->optional(),
        ]);
    }

    public function rules(): array
    {
        // This may call $this->user(), which Luminous cannot do
        return [
            'email' => ['required', 'email', Rule::unique('users')->ignore($this->user()->id)],
            'name'  => ['required', 'string', 'max:255'],
            'bio'   => ['sometimes', 'string', 'max:1000'],
        ];
    }
}

See The Shape Builder for all available methods.