Essentials

Typed Requests & Responses

Declare request and response contracts as PHP classes. The router validates input before your handler runs, envelopes output after it returns, and the same types drive the OpenAPI spec.

Glueful's typed I/O layer turns an endpoint's contract into two plain PHP classes:

  • A request DTO (Glueful\Validation\Contracts\RequestData). Type-hint it on a controller method and the router hydrates it from the JSON body, path and query string, validates it, and injects it. Invalid input never reaches your code: the client gets a 422.
  • A response DTO (Glueful\Http\Contracts\ResponseData). Return it and the router serializes it into the standard {success, message, data} envelope.

Both are read by the OpenAPI generator, so the documented schema is the one the code enforces. For the rule syntax itself, see Validation.

Request DTOs

A request DTO is a class that implements the RequestData marker interface and declares its fields as constructor parameters. #[Rule] attaches a pipe-separated rule string to each field:

namespace App\DTOs;

use Glueful\Validation\Attributes\Rule;
use Glueful\Validation\Contracts\RequestData;

final class CreatePostData implements RequestData
{
    public function __construct(
        #[Rule('required|string|max:200')]
        public readonly string $title,

        #[Rule('required|string')]
        public readonly string $body,

        #[Rule('in:draft,published')]
        public readonly string $status = 'draft',

        #[Rule('email')]
        public readonly ?string $notify = null,
    ) {}
}

Type-hint it on the handler. No middleware is needed:

use App\DTOs\CreatePostData;

public function store(CreatePostData $data): Response
{
    // $data->title, $data->body, $data->status are validated and typed
}
$router->post('/posts', [PostController::class, 'store']);

Scaffold one with:

php glueful scaffold:dto CreatePostData        # writes app/DTOs/CreatePostData.php

How Hydration Works

When the router resolves a parameter whose type implements RequestData, Glueful\Validation\RequestDataHydrator builds it in a fixed order:

  1. Resolve each field from its source: the JSON body by default, or the path/query (see Route and Query Fields).
  2. Run the #[Rule] strings over the resolved values.
  3. Hydrate arrays and nested DTOs declared with #[ArrayOf].
  4. Check required presence. A non-nullable parameter with no default that is missing from the input becomes a 422 (The {field} field is required.), not a TypeError.
  5. Fail before constructing. Any error so far throws ValidationException, so the DTO is never built with partial data.
  6. Run ValidatesSelf::validate(), if implemented, on the constructed object.

Binding rules:

  • Property names are the exact input keys. There is no snake_case/camelCase conversion: $refresh_token reads refresh_token.
  • Defaults and nullables fill in when a key is absent.
  • Scalars are coerced to the declared int, float, bool or string type after validation.
  • The body must be JSON. Form-encoded and multipart bodies are not read. An empty body counts as {}. Malformed JSON fails with a body error (Invalid JSON body.).

Give typed scalar fields a matching rule. Coercion only converts values that fit ("42" → 42). A non-numeric string reaching an int parameter with no rule to reject it surfaces as a type error rather than a 422.

The 422 Envelope

All failures, including field rules, nested elements and ValidatesSelf, merge into one response:

{
  "success": false,
  "message": "The given data was invalid.",
  "errors": {
    "title": ["This field is required."],
    "items.1.sku": ["This field is required."],
    "coupon_ids.2": ["The coupon_ids.2 field must be of type int."]
  }
}

Database rules (unique:, exists:) run against the application's connection. Custom rule names resolve through the RuleRegistry, as described in Validation.

Route and Query Fields

#[FromRoute] reads a field from the matched path parameters, and #[FromQuery] reads it from the query string. Everything else comes from the body:

use Glueful\Validation\Attributes\{FromQuery, FromRoute, Rule};
use Glueful\Validation\Contracts\RequestData;

final class UpdatePostData implements RequestData
{
    public function __construct(
        #[FromRoute(description: 'Post UUID')]
        public readonly string $uuid,                        // /posts/{uuid}

        #[FromQuery(description: 'Notify subscribers', example: 'true')]
        #[Rule('in:true,false')]
        public readonly string $notify = 'false',            // ?notify=true

        #[Rule('required|string|max:200')]
        public readonly string $title = '',                  // JSON body
    ) {}
}
$router->put('/posts/{uuid}', [PostController::class, 'update']);

public function update(UpdatePostData $data): Response { /* ... */ }
  • A field has exactly one source. Putting both attributes on one field throws a LogicException.
  • #[FromRoute] / #[FromQuery] are valid only on the DTO the handler receives, not on nested DTOs.
  • Path and query values arrive as strings. A field declared int, float or bool gets the string converted before its rules run, so #[Rule('integer|min:1')] works on ?page=2; booleans accept true/false/1/0/yes/no/on/off. A value that doesn't convert (?page=abc) is a 422: "The page field must be an integer."
  • The optional description and example arguments only feed the OpenAPI parameter.

Arrays and Nested Data

An array field is untyped unless you declare its element type with #[ArrayOf]. Use a scalar name (string, int, float, bool) or another RequestData class:

use Glueful\Validation\Attributes\{ArrayOf, Rule};
use Glueful\Validation\Contracts\RequestData;

final class LineItemData implements RequestData
{
    public function __construct(
        #[Rule('required|string')] public readonly string $sku,
        #[Rule('required|numeric')] public readonly int $quantity,
    ) {}
}

final class CreateOrderData implements RequestData
{
    public function __construct(
        #[Rule('required|array'), ArrayOf(LineItemData::class)]
        public readonly array $items,          // list<LineItemData>

        #[Rule('array'), ArrayOf('int')]
        public readonly array $coupon_ids = [], // list<int>
    ) {}
}
  • Scalar elements are checked and coerced strictly. An int element accepts 5 or "5" but not "5a", and errors are keyed coupon_ids.2.
  • Each nested element must be an object. It is hydrated and validated like a top-level DTO, with errors keyed items.0.sku.
  • Nesting is capped at 5 levels.
  • #[ArrayOf] with a class that doesn't implement RequestData throws a LogicException. An unknown type name throws when the attribute is read.

Cross-Field Validation

Implement Glueful\Validation\Contracts\ValidatesSelf for rules that span fields. validate() runs after construction, so $this is fully typed. It returns field => list<string>, and an empty array means valid:

use Glueful\Validation\Attributes\Rule;
use Glueful\Validation\Contracts\{RequestData, ValidatesSelf};

final class DateRangeData implements RequestData, ValidatesSelf
{
    public function __construct(
        #[Rule('required|date:Y-m-d')] public readonly string $from,
        #[Rule('required|date:Y-m-d')] public readonly string $to,
    ) {}

    public function validate(): array
    {
        return $this->from > $this->to
            ? ['to' => ['The end date must be after the start date.']]
            : [];
    }
}

The errors come back in the same 422 envelope.

Use either a RequestData parameter or #[Validate] + the validate middleware for a route's body, not both. The middleware runs first and can reject the request before the DTO is built.

Response DTOs

A response DTO implements the ResponseData marker interface. Its public properties become the data payload:

namespace App\DTOs;

use App\Enums\PostStatus;
use Glueful\Http\Contracts\ResponseData;

final class PostData implements ResponseData
{
    public function __construct(
        public readonly string $uuid,
        public readonly string $title,
        public readonly PostStatus $status,
        public readonly AuthorData $author,          // another ResponseData
        public readonly \DateTimeImmutable $createdAt,
    ) {}
}
public function show(string $uuid): PostData
{
    $post = Post::query($this->context)->with('author')->where('uuid', $uuid)->firstOrFail();

    return new PostData(
        uuid: $post->uuid,
        title: $post->title,
        status: PostStatus::from($post->status),
        author: new AuthorData($post->author->uuid, $post->author->name),
        createdAt: $post->created_at,          // DateTimeImmutable via the model's 'datetime' cast
    );
}
{
  "success": true,
  "message": "Success",
  "data": {
    "uuid": "018e4c12-...",
    "title": "Hello",
    "status": "published",
    "author": { "uuid": "018e3a00-...", "name": "Alice" },
    "createdAt": "2026-06-14T09:00:00+00:00"
  }
}

Glueful\Serialization\ResponseDataSerializer maps each value:

ValueSerialized as
scalar / nullas-is
backed enum->value
pure enum->name
DateTimeInterfaceISO-8601 (format('c'))
nested ResponseDatarecursed
arrayelement-wise, using the same rules
other objectits public properties, mapped recursively

Uninitialized typed properties are skipped. Property names are kept as written (createdAt stays createdAt). Self-references and nesting deeper than 5 levels render as null.

A DTO can control its own shape with a toArray(): array method. The serializer then uses its result verbatim, but the OpenAPI schema still reflects the public properties, so the two can drift.

Generate a response DTO with php glueful scaffold:dto PostData --response.

Status and Message

#[ResponseStatus] sets a 2xx success status. A non-2xx value throws InvalidArgumentException when the attribute is read:

use Glueful\Routing\Attributes\ResponseStatus;

#[ResponseStatus(201)]
public function store(CreatePostData $data): PostData
{
    // → 201 {"success": true, "message": "Created successfully", "data": {...}}
}

The default message is Success (or Created successfully for 201). To supply your own, implement Glueful\Http\Contracts\HasResponseMessage and keep the message in a private property so it doesn't leak into data:

use Glueful\Http\Contracts\{HasResponseMessage, ResponseData};

final class PasswordResetData implements ResponseData, HasResponseMessage
{
    public function __construct(
        public readonly string $email,
        private readonly string $message = 'Reset link sent',
    ) {}

    public function responseMessage(): string
    {
        return $this->message;
    }
}

Collections and Pagination

Return Glueful\Http\Responses\CollectionResponse for a list. Each ResponseData item is serialized, and arrays and scalars pass through unchanged:

use Glueful\Http\Responses\CollectionResponse;

/** @return CollectionResponse<PostData> */
public function index(): CollectionResponse
{
    $posts = Post::query($this->context)->with('author')->latest()->limit(50)->get();

    return new CollectionResponse(
        $posts->map(fn (Post $p) => PostData::fromModel($p))->all()
    );
}
// → {"success": true, "message": "Success", "data": [ {...}, {...} ]}

#[ResponseStatus] applies to a CollectionResponse too.

Return Glueful\Http\Responses\PaginatedResponse for a page. Its constructor takes ($items, $page, $perPage, $total) and rejects page < 1, perPage < 1 or total < 0:

use Glueful\Http\Responses\PaginatedResponse;
use Glueful\Validation\Attributes\{FromQuery, Rule};
use Glueful\Validation\Contracts\RequestData;

final class ListPostsData implements RequestData
{
    public function __construct(
        #[FromQuery] #[Rule('numeric')] public readonly int $page = 1,
        #[FromQuery] #[Rule('numeric')] public readonly int $per_page = 20,
    ) {}
}

/** @return PaginatedResponse<PostData> */
public function index(ListPostsData $q): PaginatedResponse
{
    $page = Post::query($this->context)
        ->where('status', 'published')
        ->with('author')
        ->latest()
        ->paginate($q->page, $q->per_page);   // 'data' is a Collection of models

    return new PaginatedResponse(
        $page['data']->map(fn (Post $p) => PostData::fromModel($p))->all(),
        $q->page,
        $q->per_page,
        $page['total'],
    );
}
{
  "success": true,
  "message": "Data retrieved successfully",
  "data": [ {...} ],
  "current_page": 1,
  "per_page": 20,
  "total": 135,
  "total_pages": 7,
  "has_next_page": true,
  "has_previous_page": false
}

A paginated response is always 200, and #[ResponseStatus] has no effect on it.

The @return CollectionResponse<PostData> docblock is optional at runtime. The OpenAPI generator reads it to document the item schema.

(PostData::fromModel() in these examples is your own static constructor. Mapping models into DTOs keeps the API contract independent of the table.)

What Else a Handler Can Return

Return valueResponse
ResponseData{success, message, data} at the #[ResponseStatus] status
CollectionResponse{success, message, data: [...]} at the #[ResponseStatus] status
PaginatedResponseFlat pagination envelope, always 200
JsonResource, ResourceCollection, PaginatedResourceResponseThe resource's own toResponse(), with #[ResponseStatus] passed through
A ResponseUnchanged
Other array or objectRaw JsonResponse, no envelope

Next Steps