Typed Requests & Responses
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 a422. - 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:
- Resolve each field from its source: the JSON body by default, or the path/query (see Route and Query Fields).
- Run the
#[Rule]strings over the resolved values. - Hydrate arrays and nested DTOs declared with
#[ArrayOf]. - 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 aTypeError. - Fail before constructing. Any error so far throws
ValidationException, so the DTO is never built with partial data. - 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_tokenreadsrefresh_token. - Defaults and nullables fill in when a key is absent.
- Scalars are coerced to the declared
int,float,boolorstringtype after validation. - The body must be JSON. Form-encoded and multipart bodies are not read. An empty body counts as
{}. Malformed JSON fails with abodyerror (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,floatorboolgets the string converted before its rules run, so#[Rule('integer|min:1')]works on?page=2; booleans accepttrue/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
descriptionandexamplearguments 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
intelement accepts5or"5"but not"5a", and errors are keyedcoupon_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 implementRequestDatathrows aLogicException. 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:
| Value | Serialized as |
|---|---|
scalar / null | as-is |
| backed enum | ->value |
| pure enum | ->name |
DateTimeInterface | ISO-8601 (format('c')) |
nested ResponseData | recursed |
| array | element-wise, using the same rules |
| other object | its 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 value | Response |
|---|---|
ResponseData | {success, message, data} at the #[ResponseStatus] status |
CollectionResponse | {success, message, data: [...]} at the #[ResponseStatus] status |
PaginatedResponse | Flat pagination envelope, always 200 |
JsonResource, ResourceCollection, PaginatedResourceResponse | The resource's own toResponse(), with #[ResponseStatus] passed through |
A Response | Unchanged |
| Other array or object | Raw JsonResponse, no envelope |
Next Steps
- Validation: every rule, custom rules, and
#[Validate]/FormRequest - OpenAPI Generation: how these types become the spec
- Requests & Responses: reading raw input and the
Responsehelpers - ORM Models: loading the data you map into response DTOs