solophp / request-handler
Robust request validation & authorization layer for HTTP inputs with type-safe handlers and modern PHP 8+ architecture
Requires
- php: ^8.2
- psr/http-message: ^2.0
- solophp/contracts: ^1.3
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.3
- squizlabs/php_codesniffer: ^3.13
Suggests
- solophp/validator: ^2.2
README
Type-safe Request DTOs for PHP 8.2+ with focused per-property attributes, declarative validation, type casting, and full IDE support.
Features
- Per-property source binding — default source comes from the handle method (
handleBody→ Body,handleQuery→ Query);#[FromRoute]and#[FromContext]override per property. No implicit merging, no surprises. - Attribute-composed DTOs — each concern is its own focused attribute:
#[Validate],#[Cast],#[PostProcess],#[Generator],#[Items], … - Convention over configuration — every public non-static property is managed; attributes only add behaviour
- Automatic type casting — driven by the property's declared type; built-ins for int, float, bool, string, array, DateTime + custom casters
- Validation rules — passed verbatim to your
ValidatorInterface; route and context values land in the validation payload as regular fields, so cross-field rules (exists:…,scope_col,{field}) work without special syntax - Generators — auto-generate UUIDs, sequences, custom values via
#[Generator] - Pre/post processing —
#[PreProcess]/#[PostProcess]for transformation - Nested items —
#[Items]validates arrays of nested Request DTOs with dot-notation error paths - Fail-fast — every misconfiguration throws
ConfigurationExceptionat metadata-build time, not at first request - Schema export —
schema()emits JSON-serializable per-field metadata (type, nullability, requiredness incl. conditionalrequired_if, normalized rules) so the frontend can treat the backend as the single source of truth
Installation
composer require solophp/request-handler
Quick Example
use Solo\RequestHandler\Attributes\{Validate, Generator, Items, Exclude}; use Solo\RequestHandler\Attributes\Source\{FromRoute, FromContext}; use Solo\RequestHandler\Request; final class UpdateProductRequest extends Request { // PUT /products/{id} #[FromRoute] #[Validate('required|integer|exists:products,id')] public int $id; // default body — no source attribute needed #[Validate('required|string|max:255')] public string $name; #[Validate('required|numeric|min:0')] public float $price; #[Validate('nullable|integer|min:0')] public int $stock = 0; #[FromContext('tenantId')] #[Validate('required|integer')] public int $tenantId; #[FromContext('authUserId')] #[Validate('required|integer')] public int $updatedBy; #[Validate('required|string|min:8')] #[Exclude] // validated/processed but hidden from toArray() public string $newPassword; } // Controller $dto = $requestHandler->handleBody( UpdateProductRequest::class, $request, route: ['id' => $productId], context: ['tenantId' => $tenant->id(), 'authUserId' => $auth->id()], ); $dto->id; // int — pulled from URL path $dto->name; // string — from body $dto->tenantId; // int — from application context $dto->updatedBy; // int — from application context $dto->toArray(); // ['id'=>…, 'name'=>…, 'price'=>…, 'stock'=>…, 'tenantId'=>…, 'updatedBy'=>…] — no newPassword
Attribute family
| Attribute | Purpose |
|---|---|
#[Validate('rules')] |
Validation rules (passed to your ValidatorInterface) |
#[FromRoute(?string $key = null)] |
Read from the $route argument (URL path params) |
#[FromContext(string $key)] |
Read from the $context argument (auth user, tenant, …) |
#[FromBody(string $path)] |
Read from a nested body path (customer.id); flat keys may also be remapped this way |
#[Cast('int'|'float'|'datetime:Y-m-d'|...)] |
Explicit built-in cast |
#[Caster(MoneyCaster::class)] |
Custom CasterInterface implementation |
#[PreProcess(MyProc::class)] |
Run handler BEFORE validation |
#[PostProcess(MyProc::class, config: [...])] |
Run handler AFTER validation, with optional config |
#[Generator(UuidGenerator::class, options: [...])] |
Value is generated, not read from any source |
#[Items(OrderItemRequest::class)] |
Property is an array of nested DTOs |
#[Group('criteria', mapTo: 'positions.id')] |
Group membership + remap for Request::group() |
#[Exclude] |
Request::toArray() skips this property (still validated & processed) |
#[Ignore] |
Property is invisible to the handler AND to toArray()/has()/get()/group() |
Properties without a source attribute read from the default-source bag (Body for handleBody/handleArray, Query for handleQuery) by name. For values that don't fit the four bags (Body, Query, Route, Context) — request headers, cookies, file uploads — extract them in the controller and pass them via route: [...] or context: [...].
To opt a public property out of input processing entirely, mark it with #[Ignore] — the handler skips it and toArray()/has()/get()/group() will not see it either. Or change its visibility to protected/private — the handler only touches public properties.
Schema introspection
schema() reflects a request class into a JSON-serializable description of its input contract — without touching any HTTP input. Hand it to the frontend so validation rules live in exactly one place.
$schema = $requestHandler->schema(UpdateProductRequest::class); // [ // 'id' => [ // 'type' => 'int', 'nullable' => false, 'required' => true, // 'requiredIf' => null, 'hasDefault' => false, 'exclude' => false, // 'source' => 'route', // 'rules' => [['name' => 'required', 'args' => []], ['name' => 'integer', 'args' => []]], // ], // 'name' => [ // 'type' => 'string', 'nullable' => false, 'required' => true, // 'requiredIf' => null, 'hasDefault' => false, 'exclude' => false, 'source' => null, // 'rules' => [['name' => 'required', 'args' => []], ['name' => 'string', 'args' => []], ['name' => 'max', 'args' => ['255']]], // ], // 'slug' => [ // 'type' => 'string', 'nullable' => true, 'required' => false, // 'requiredIf' => ['field' => 'status', 'value' => 'published'], // 'hasDefault' => true, 'exclude' => false, 'source' => null, // 'rules' => [/* … incl. ['name' => 'required_if', 'args' => ['status', 'published']] */], // ], // ] echo json_encode($schema); // round-trips losslessly — scalars and arrays only
Every managed property is emitted (excluded/#[FromRoute]/#[FromContext] fields included) — exclude and source are reported as flags so the consumer decides what to render. Notes:
requiredreflects the literalrequiredrule only. Conditional requiredness lives inrequiredIf(the first well-formedrequired_if, as{field, value}), and the rule itself is still present inrules.sourceis'body' | 'query' | 'route' | 'context', ornullwhen the field uses the handle method's default bag.rulesis the full normalized rule list, split with the same semantics the handler uses internally:nameis the part before the first:,argsis the comma-split remainder.
Documentation
- Installation
- Quick Start
- Configuration
- Attributes
- Type Casting
- Processors
- Generators
- Nested Items
- Validation
- API Reference
Requirements
- PHP 8.2+
- PSR-7 HTTP Message implementation
- Validator implementing
Solo\Contracts\Validator\ValidatorInterface
We recommend solophp/validator.
License
MIT License. See LICENSE for details.