orange/dto is a small, dependency-free PHP 8.4+ package for building
validated, filtered data transfer objects (DTOs) from raw input arrays using
PHP attributes.
You declare a DTO class that extends orange\dto\Dto, annotate each
publicly readable property with attributes, and the package will:
- read each value from the incoming input array (by field name)
- validate every value against the rules you declared
- filter / cast values (trim, lower-case, cast to int, etc.)
- expose the valid data as typed properties and as array / column shapes, per table or whole
- collect human-readable error messages for anything that failed
Status: work in progress. The public API described here is stable and covered by tests, but new attributes are still being added.
- PHP
>= 8.4 - Extensions:
ext-filter,ext-json,ext-mbstring(declared incomposer.json)
composer require orange/dto<?php
declare(strict_types=1);
namespace app\request;
use orange\dto\Dto;
use orange\dto\attributes\Column;
use orange\dto\attributes\FieldName;
use orange\dto\attributes\Label;
use orange\dto\attributes\Table;
use orange\dto\attributes\filters\ToInteger;
use orange\dto\attributes\filters\ToString;
use orange\dto\attributes\filters\Trim;
use orange\dto\attributes\validations\Between;
use orange\dto\attributes\validations\IsRequired;
use orange\dto\attributes\validations\MaxLength;
use orange\dto\attributes\validations\MinLength;
class UserRequest extends Dto
{
#[Trim]
#[ToString]
#[IsRequired]
#[MinLength(1)]
#[MaxLength(64)]
#[Column('name')]
#[Table('user')]
#[Label('Name')]
public protected(set) string $name;
#[ToInteger]
#[IsRequired]
#[Between(18, 110)]
#[Column('age')]
#[Table('user')]
#[Label('Age')]
public protected(set) int $age;
#[Trim]
#[ToString]
#[IsRequired]
#[MinLength(4)]
#[MaxLength(16)]
#[FieldName('clr')] // read from input key "clr"
#[Column('fav_color')] // store as column "fav_color"
#[Table('user')]
#[Label('Favorite Color')]
public protected(set) string $color;
}
$request = new UserRequest([
'name' => 'Johnny Appleseed',
'age' => '23',
'clr' => 'Orange',
]);
if ($request->isValid()) {
echo $request->name; // "Johnny Appleseed"
echo $request->age; // 23 (int)
// the class names a #[Table], so asColumns() asks by name
print_r($request->asColumns(tablename: 'user')); // ['name' => ..., 'age' => 23, 'fav_color' => 'Orange']
} else {
print_r($request->errors());
}When you construct a request, it uses reflection to find every publicly
readable property that carries one or more orange\dto attributes — plain
public and asymmetric public protected(set) both qualify. For each such
property it:
- resolves the field name (input key), column, table, and label from the metadata attributes (falling back to the property name);
- reads the raw value from the input array using the field name;
- walks the attributes in declaration order, calling each rule's
validate()and/orfilter()method against the current value; - if every validation passed, assigns the (possibly filtered) value to the typed property and records it in the array / column / table outputs. If anything failed, the messages are collected under the field name instead.
Properties with no orange\dto attributes are ignored entirely.
Validations and filters run in a single pass in declaration order, and a
filter changes the value seen by later attributes. Declare value-shaping filters
(Trim, ToInteger, ToLower, …) before the validations that depend on
them:
#[Trim] // " 9 " -> "9"
#[ToInteger] // "9" -> 9
#[Between(1, 10)] // validates 9
public int $qty;| Method | Returns |
|---|---|
isValid(): bool |
true when there are no errors |
errors(): array |
['fieldName' => ['message', ...], ...] |
allErrors(): array |
errors() plus nested DTO detail, dot-keyed: ['lines.1.sku' => [...], ...] |
asArray(): array |
valid values keyed by property name |
asColumns(bool $withoutPrimary = false, ?string $tablename = null): array |
valid values keyed by column name; $withoutPrimary drops the #[IsPrimary] columns, $tablename restricts to the properties tagged #[Table] for that table — required, and must be one the class names, when the class names any |
tables(): ?array |
the tables the class names, in declaration order; null when it names none |
only(string ...$props): array |
asArray() restricted to the given property names |
except(string ...$props): array |
asArray() without the given property names |
input(?string $key = null, mixed $default = ''): mixed |
the raw, unprocessed input (whole array, or one key) |
A class that names no #[Table] describes one table — whichever one the
model holding it writes to. asColumns() hands back every valid property and
needs no table name; any name passed is simply that table's.
A class that does name tables describes several, and "every column" is then
not an answer any one of them can use. The $tablename says which table is
asking, and only the properties tagged for it come back. tables() is how you
find out which case you are in — and omitting the name when the class names
tables throws a LogicException, because there is no sensible default:
class UserProfile extends Dto
{
#[IsRequired] #[Table('users')]
public string $name;
#[IsRequired] #[Table('user_meta')]
public string $bio;
#[IsRequired] // no table: nobody's column in particular
public string $confirmToken;
}
$request->tables(); // ['users', 'user_meta']
$request->asColumns(tablename: 'users'); // ['name' => 'Ada']
$request->asColumns(tablename: 'user_meta'); // ['bio' => 'Engineer']
$request->asColumns(); // LogicException - which table is asking?
$request->asColumns(tablename: 'sessions'); // LogicException - not a table it names$confirmToken carries no #[Table], so it reaches neither — which is what you
want for a field that validates but never persists.
Both errors are bugs rather than values: a class's tables are fixed at its first construction, so a name it does not have is a typo or the wrong DTO for the job and will be wrong for every instance. A table it does name but has nothing valid under simply comes back empty — that is a reading of the data:
$invalid = new UserProfile(['name' => '']);
$invalid->isValid(); // false
$invalid->tables(); // ['users', 'user_meta'] - a declaration
$invalid->asColumns(tablename: 'users'); // [] - nothing passedThe single-table case, for contrast — no #[Table] anywhere:
$token->tables(); // null
$token->asColumns(); // every column
$token->asColumns(tablename: 'tokens'); // the same, the name is simply its ownMore than one property may be tagged #[IsPrimary]: several in one table make a
compound key, and one per #[Table] gives a multi-table DTO a key for each.
primaries() and primaryValues() return them all, in declaration order and
keyed exactly as asColumns() keys them — array_keys($dto->primaryValues())
always equals $dto->primaries().
The singular primary()/primaryValue() are for the ordinary single-key
record. Faced with more than one they throw a LogicException rather than
answer with half a key, which fed to a WHERE clause would match rows you never
meant. Narrow them with a table name, or ask the plural form:
class OrderLineDto extends Dto
{
#[IsPrimary] #[Column('order_id')] public int $orderId;
#[IsPrimary] #[Column('line_no')] public int $lineNo;
#[IsRequired] public string $sku;
}
$line->primaries(); // ['order_id', 'line_no']
$line->primaryValues(); // ['order_id' => 7, 'line_no' => 2]
$line->primary(); // LogicException - use primaries()
$line->asColumns(withoutPrimary: true); // ['sku' => 'A1'] - both halves goWith #[Table], each table's key is asked for by name — and $withoutPrimary
drops only the key of the table in scope, so a second table keeping its own id
column keeps it:
$profile->primaryValue('users'); // 7
$profile->primaryValues('user_meta'); // ['user_id' => 7]
$profile->primaryValue(); // LogicException - two of them
$profile->asColumns(true, 'users'); // users' columns, minus users' keyA class that tags no #[Table] at all has only the one table it was written
for, so its key answers to any $tablename the caller asks by.
A primary that failed validation is absent from primaryValues() rather
than null — compare its count() against primaries() to spot a partial key.
$withoutPrimary produces the shape for insert/update SET clauses — the
primary is auto-assigned on insert and targeted through the WHERE on update,
so it is never a SET column:
$sql->insert()->set($dto->asColumns(withoutPrimary: true));
$sql->update()->set($dto->asColumns(withoutPrimary: true))->wherePrimary($dto->primaryValue());except() is the answer to the "every valid field is mapped into every output"
gotcha below — drop fields that validate but never persist:
$registration->except('passwordConfirmation'); // everything else, keyed by propertyDto implements JsonSerializable: json_encode($dto) — or a list of DTOs —
emits exactly asArray(). Invalid fields are omitted and engine internals can
never leak into the encoding, so a DTO (or an array of them) can be passed
straight to a JSON response.
__debugInfo() curates var_dump($dto) down to what matters — the validity
flag, the validated values, and the errors — instead of the raw input and
internal bookkeeping structures.
| Method | Returns |
|---|---|
validKeys(bool $raw = true): array |
keys of fields that passed |
invalidKeys(bool $raw = true): array |
keys of fields that failed |
validInputKeys(): array |
passed fields, as resolved input field names |
invalidInputKeys(): array |
failed fields, as resolved input field names |
primaries(?string $tablename = null): array |
the #[IsPrimary] columns, in declaration order; empty when none is tagged |
primaryValues(?string $tablename = null): array |
those columns to their validated values — a primary that failed validation is absent |
primary(?string $tablename = null): ?string |
the one #[IsPrimary] column; null when none is tagged, throws when there is more than one |
primaryValue(?string $tablename = null): mixed |
the one primary's validated value — null when none is tagged or it failed; throws when there is more than one |
By default ($raw = true) these return the raw property names. Pass false
— or use the *InputKeys() wrappers — to get the resolved input field names (the
remapped FieldName values). For the color/clr property above:
$request->validKeys(); // ['name', 'age', 'color'] (property names)
$request->validInputKeys(); // ['name', 'age', 'clr'] (input field names)$request->fieldName('color'); // 'clr'
$request->column('color'); // 'fav_color'
$request->table('color'); // 'user'
$request->label('color'); // 'Favorite Color'fieldName(), column() and label() fall back to the property name when the
corresponding attribute is absent. table() returns null instead — naming a
table for an untagged property would name a table the property is not in.
Given the UserRequest above with valid input:
$request->asArray();
// ['name' => 'Johnny Appleseed', 'age' => 23, 'color' => 'Orange']
$request->tables();
// ['user'] - so asColumns() must be told which table is asking
$request->asColumns(tablename: 'user');
// ['name' => 'Johnny Appleseed', 'age' => 23, 'fav_color' => 'Orange']
$request->asColumns(tablename: 'audit');
// null - no property is tagged #[Table('audit')]| Attribute | Purpose |
|---|---|
#[FieldName('key')] |
input array key to read from (defaults to property name) |
#[Column('col')] |
column name used by asColumns() (defaults to property name) |
#[Table('name', 'database')] |
the table this property belongs to, which asColumns($tablename) filters on and tables() reports; optional database identifier. Without it — or with an empty name — the property belongs to no table and only ever appears in an unnamed asColumns() |
#[Label('Human Name')] |
name used in error messages (defaults to property name) |
#[IsPrimary] |
tags a property holding part of the record's primary key — a pure marker. Retrievable via primaries()/primaryValues(). Several tagged properties make a compound or per-table key |
#[DbCast('int')] |
scalar cast (int, float, string, bool) applied to the value in asColumns() only — the typed property, asArray(), and JSON keep the domain value. null is never cast. An unknown target throws InvalidArgumentException at the class's first construction |
Filters shape the domain value on the way in; DbCast shapes the
storage value on the way out. The motivating case is a bool property whose
column is an integer — without the cast, binding PHP false sends '', which
strict-mode MySQL rejects for an int column:
#[IsBoolean]
#[ToBoolean]
#[DbCast('int')]
public bool $in_office;$dto->in_office; // true (domain: bool, also in asArray()/JSON)
$dto->asColumns(); // ['in_office' => 1] (storage: int, ready to bind)
// (this class names no #[Table], so no name is needed)Filters transform the value and never fail.
| Attribute | Effect |
|---|---|
#[Trim] |
strips surrounding whitespace |
#[CollapseSpaces] |
collapses internal whitespace runs to single spaces and trims |
#[StripTags] |
removes HTML/PHP tags |
#[ToLower] / #[ToUpper] |
multibyte case conversion |
#[StrLimit(int $length)] |
truncates a string to $length characters |
#[ToBoolean] |
true/"true"/"yes"/"on"/"1"/non-zero int → true; everything else → false |
#[ToInteger] |
casts to int |
#[ToFloat] |
casts to float |
#[ToString] |
casts to string |
#[NullIfEmpty] |
converts an empty string '' to null (leaves '0' / 0 alone) |
#[DefaultTo(mixed $default = null)] |
substitutes $default when the value is null or '' |
#[Slugify] |
lower-cases and converts to a hyphen-separated slug ("My Post!" → "my-post") |
#[HtmlEncode] |
encodes HTML special characters (htmlspecialchars, ENT_QUOTES) |
#[Round(int $precision = 0)] |
rounds numeric input to $precision decimal places |
#[OnlyDigits] |
strips every non-digit character |
#[UcWords] |
title-cases each word (multibyte-safe) |
#[UcFirst] |
upper-cases just the first character (multibyte-safe) |
#[HtmlDecode] |
decodes HTML entities — the inverse of #[HtmlEncode] |
#[OnlyAlpha] |
strips every non-letter character |
#[OnlyAlphaNumeric] |
strips every character that is not a letter or digit |
#[Clamp(int|float $min, int|float $max)] |
forces numeric input into [min, max] — the filter counterpart of #[Between] |
#[Ceil] / #[Floor] |
rounds numeric input up / down to a whole number |
#[Abs] |
absolute value of numeric input |
#[StripControlChars] |
removes control and zero-width characters (keeps tabs and newlines) |
#[NormalizeLineEndings] |
converts \r\n and \r line endings to \n |
#[StripSpaces] |
removes all whitespace (card numbers, codes) |
#[Transliterate] |
folds accents to ASCII (é → e); uses intl when available, iconv otherwise |
#[Pad(int $length, string $padString = '0')] |
left-pads strings/integers to a fixed length (42 → 00042) |
#[NormalizeDateTime(string $format = 'Y-m-d H:i:s')] |
reformats any strtotime()-parseable date to a canonical format; unparseable input passes through |
#[NormalizePhone] |
strips phone formatting, keeping digits and a leading + |
Every validation attribute also accepts an optional custom message as its last constructor argument (see Custom error messages).
| Attribute | Passes when the value… |
|---|---|
#[Alpha] |
contains only letters |
#[AlphaDash] |
contains only letters and dashes |
#[AlphaNumeric] |
contains only letters and digits |
#[AlphaNumericSpaces] |
contains only letters, digits, and spaces |
#[Slug] |
is a lower-case, hyphen-separated slug (my-post-1) |
#[StartsWith(string $needle)] |
starts with $needle |
#[EndsWith(string $needle)] |
ends with $needle |
#[Contains(string $needle)] |
contains $needle |
#[NotContains(string $needle)] |
does not contain $needle |
#[RegexMatch(string $pattern)] |
matches the PCRE $pattern |
#[NotRegexMatch(string $pattern)] |
does not match the PCRE $pattern |
| Attribute | Passes when the value… |
|---|---|
#[Numeric] |
is numeric |
#[Integer] |
is an integer |
#[Decimal] |
is a decimal number (has a fractional part) |
#[IsNatural] |
is a natural number (0 and up) |
#[IsNaturalNoZero] |
is a natural number greater than zero |
#[GreaterThan(int|float $value)] |
is greater than $value |
#[GreaterThanEqualTo(int|float $value)] |
is greater than or equal to $value |
#[LessThan(int|float $value)] |
is less than $value |
#[LessThanEqualTo(int|float $value)] |
is less than or equal to $value |
#[Between(int|float $min, int|float $max)] |
is within [min, max] (inclusive) |
#[MultipleOf(int|float $step)] |
is an exact multiple of $step |
| Attribute | Passes when the string length… |
|---|---|
#[ExactLength(int $length)] |
equals $length |
#[MaxLength(int $length)] |
is strictly less than $length |
#[MinLength(int $length)] |
is strictly greater than $length |
#[BetweenLength(int $min, int $max)] |
is within [min, max] (inclusive) |
Note:
MaxLengthandMinLengthare strict (<and>), matching their messages ("must be less/greater than N characters"). UseBetweenLengthorExactLengthwhen you need inclusive bounds.
| Attribute | Passes when… |
|---|---|
#[IsRequired] |
the value is "filled" — not null, '', or [] ('0' and 0 do count as filled) |
#[Matches(string $field)] |
the value equals another field's input value |
#[Differs(string $field)] |
the value differs from another field's input value |
#[RequiredIf(string $field, string $value)] |
present, but only required when $field equals $value |
#[RequiredWith(string $field)] |
present, but only required when $field is filled |
#[RequiredUnless(string $field, string $value)] |
required except when $field equals $value |
#[RequiredWithout(string $field)] |
required when $field is empty |
#[ProhibitedIf(string $field, string $value)] |
must be empty when $field equals $value |
#[ProhibitedWith(string $field)] |
must be empty when $field is filled — the two fields are mutually exclusive |
#[InList(array $values)] |
is one of $values |
#[NotInList(array $values)] |
is none of $values |
#[Equals(mixed $value)] |
equals the fixed literal $value (compared as strings) |
#[NotEquals(mixed $value)] |
differs from the fixed literal $value (compared as strings) |
#[Accepted] |
is a truthy "checkbox" value: true, 1, '1', 'yes', 'on', or 'true' |
| Attribute | Passes when the value is… |
|---|---|
#[ValidEmail] |
a valid email address |
#[ValidEmails] |
a comma-separated list of valid email addresses |
#[ValidUrl] |
a valid URL |
#[ValidHostname] |
a valid hostname |
#[ValidIp(string $version = '')] |
a valid IP ('' = any, 'ipv4', or 'ipv6') |
#[ValidDate] |
a date string parseable by strtotime() |
#[DateFormat(string $format = 'Y-m-d')] |
an exact match for the given date $format |
#[Before(string $date)] |
a date strictly before $date (anything strtotime() understands, including 'now') |
#[After(string $date)] |
a date strictly after $date (anything strtotime() understands, including 'now') |
#[MinAge(int $years)] |
a date at least $years years in the past (date-of-birth rules) |
#[MaxAge(int $years)] |
a date no more than $years years in the past |
#[ValidUlid] |
a ULID (26 characters of Crockford base32) |
#[ValidIban] |
an IBAN, verified with the ISO 7064 mod-97 checksum (spaces and case ignored) |
#[ValidIsbn] |
an ISBN-10 or ISBN-13 including its checksum (hyphens and spaces ignored) |
#[ValidLuhn] |
passes the Luhn mod-10 checksum (IMEIs, account numbers; use #[ValidCreditCard] for cards) |
#[ValidMacAddress] |
a MAC address (colon, hyphen, or dot notation) |
#[ValidPort] |
a network port number (1–65535) |
#[ValidSemver] |
a semantic version per semver.org 2.0.0 (1.2.3, 2.0.0-rc.1) |
#[ValidFilename] |
a safe bare filename — no separators, traversal, control characters, or null bytes |
Multi-select and checkbox-group inputs arrive as arrays; these rules validate the array itself:
| Attribute | Passes when the value… |
|---|---|
#[IsArray(?string $dtoClass = null)] |
is an array; given a Dto class, each element is also built and validated as a child DTO (see Nested DTOs) |
#[MinCount(int $count)] |
is an array with at least $count elements |
#[MaxCount(int $count)] |
is an array with at most $count elements |
#[InListEach(array $values)] |
is an array whose every element is one of $values |
#[BeforeField(string $field)] |
a date strictly before another field's date value |
#[AfterField(string $field)] |
a date strictly after another field's date value |
#[ValidTimezone] |
a valid PHP timezone identifier |
#[ValidJson] |
a well-formed JSON string |
#[ValidUuid] |
a valid RFC 4122 UUID (versions 1–8) |
#[ValidBase64] |
a valid base64 string |
#[ValidHexColor] |
a 3- or 6-digit hex color, with optional leading # |
#[ValidCreditCard] |
a 13–19 digit number passing the Luhn checksum |
#[ValidPhoneNumber] |
a plausible phone number — a loose check, not strict E.164 (formatting characters are stripped, then 7–15 digits with an optional leading + are required) |
#[ValidCountryCode] |
a valid ISO 3166-1 alpha-2 country code (case-insensitive) |
#[ValidCurrencyCode] |
a valid ISO 4217 alpha-3 currency code (case-insensitive) |
#[IsArray(Child::class)] turns a property into an array of child DTOs. Each
element of the input value must itself be an array; each one is passed to
new Child($element) — built, filtered, and validated exactly like a
stand-alone DTO — with the input keys preserved:
class LineItem extends Dto
{
#[IsRequired]
#[ToString]
#[Column('sku')]
#[Table('order_lines')]
#[Label('Sku')]
public protected(set) string $sku;
#[IsRequired]
#[ToInteger]
#[GreaterThan(0)]
#[Column('qty')]
#[Table('order_lines')]
#[Label('Qty')]
public protected(set) int $qty;
}
class OrderRequest extends Dto
{
#[IsRequired]
#[IsArray(LineItem::class)]
#[MinCount(1)]
#[MaxCount(10)]
#[Label('Lines')]
public protected(set) array $lines;
}
$request = new OrderRequest([
'lines' => [
['sku' => 'A1', 'qty' => '2'],
['sku' => 'B2', 'qty' => 1],
],
]);
$request->lines[0]; // a LineItem instance
$request->lines[0]->qty; // 2 (filtered by the child's own ToInteger)#[MinCount] / #[MaxCount] bound the element count as usual, and the class
argument is verified up front — a non-Dto class throws at the owning class's
first construction.
A child failure never floods the parent's errors(). The parent reports a
single message per problem, most fundamental first:
| Situation | Parent error |
|---|---|
| the value is not an array | Lines must be an array |
| an element is not itself an array | Lines contains a non-object entry |
| one or more children failed validation | Lines has 1 or more errors |
For the detail, extract the property — it is assigned the child DTOs even when they failed, so each child can be inspected like any other DTO:
if (!$request->isValid()) {
foreach ($request->lines as $index => $line) {
if (!$line->isValid()) {
$lineErrors = $line->errors(); // a normal DTO errors() array
}
}
}When the consumer doesn't know the shape — a generic API error body, a log
entry — allErrors() exports the full tree in one call, dot-keyed by input
field name and element key, recursing through any deeper dto-arrays:
$request->allErrors();
// [
// 'lines' => ['Lines has 1 or more errors'],
// 'lines.1.sku' => ['Sku is required'],
// 'lines.1.qty' => ['Quantity must be between 1 and 99'],
// ]The output shapes are unaffected by this extraction escape hatch: an invalid
dto-array field is omitted from asArray() / JSON like any other invalid
field.
asArray() (and therefore json_encode(), only(), except()) flattens
each child through its own asArray(), so the result is nested plain arrays
all the way down — no objects to unwrap:
$request->asArray();
// ['lines' => [['sku' => 'A1', 'qty' => 2], ['sku' => 'B2', 'qty' => 1]]]The db shapes are the exception: a nested structure has no single-row
table/column representation, so asColumns() on the parent skips dto-array
properties entirely. Persist the children individually:
foreach ($request->lines as $line) {
$db->insert('order_lines', $line->asColumns());
}An absent optional dto-array normalizes to [], so the typed array
property is always safe to iterate.
Every validation attribute accepts a custom message string. It is passed through sprintf()
with the label as the first argument, followed by any rule-specific values, so you
can use %s placeholders (or none at all):
#[IsRequired('You must provide a name.')]
public string $name;
#[MinLength(8, '%s must be at least %s characters long.')]
public string $password; // -> "Password must be at least 8 characters long."The rule-specific values match what appears in the default message (for
MinLength that is the length; for Between the min then max, etc.). Use %% to
output a literal percent sign.
- Declare properties
public protected(set)to make the DTO immutable. The engine assigns validated values from inside the class hierarchy, so asymmetric visibility costs nothing — but it stops outside code from overwriting a property after validation, meaning an instance can only ever hold what its rules let through. Plainpublicstill works if you want writable properties. - Only valid fields appear in output. A field that fails validation is not
assigned to its typed property (reading it throws an "uninitialized" error) and
is excluded from
asArray()/asColumns(). - Typed properties must match filtered values. If a property is typed
int, make sure a cast filter such as#[ToInteger]runs, otherwise assigning a string will raise aTypeError. - A field with no
#[Column]still appears inasArray()/asColumns()under its property name — filter such fields (e.g. a password confirmation) out downstream.#[Table]is the one exception with no property-name fallback: an untagged field belongs to no table, soasColumns('name')never returns it andtable('field')returnsnull. - Format validators always run. There is no "sometimes" concept — a format
validator like
#[ValidEmail]will fail on an empty value even alongside#[RequiredIf]. Pair conditional rules with presence-only checks. - "Filled" isn't PHP's
empty().#[IsRequired],#[RequiredIf], and#[RequiredWith]treat a value as present unless it'snull,'', or[]— the string'0'and the integer0both count as filled, unlikeempty().
orange/dto has no dependency on orange/framework — a Dto subclass
just needs a plain array. The only framework touchpoint is where that array
comes from, which is the framework's Input service
(orange\framework\interfaces\InputInterface, wired up as $this->input by
orange\framework\controllers\BaseController via #[AttachService('input')]).
Input exposes the request body and the query string separately — there's no
combined "all input" method:
$this->input->request()— the POST/PUT/PATCH body (also parses JSON bodies)$this->input->query()— the query string ($_GET)
If an endpoint needs both, merge them yourself, e.g.
array_merge($this->input->query(), $this->input->request()) (later keys win).
namespace app\users\controllers;
use orange\framework\controllers\BaseController;
use app\users\requests\CreateUserRequest;
use app\users\requests\SearchUsersRequest;
class UserController extends BaseController
{
public function store(): string
{
$request = new CreateUserRequest($this->input->request());
if (!$request->isValid()) {
// handle $request->errors() however this controller reports failures
}
// ... persist $request->asColumns(tablename: 'user') ...
return '';
}
public function search(): string
{
$request = new SearchUsersRequest($this->input->query());
// ...
return '';
}
}To avoid instantiating it by hand in every method, register a factory in
config/services.php — the same pattern this app already uses for its files
service — and pull it in with #[AttachService]:
// config/services.php
use orange\framework\interfaces\ContainerInterface;
use app\users\requests\CreateUserRequest;
return [
'createUserRequest' => function (ContainerInterface $container) {
return new CreateUserRequest($container->input->request());
},
];use orange\framework\attributes\AttachService;
use orange\framework\controllers\BaseController;
use app\users\requests\CreateUserRequest;
class UserController extends BaseController
{
#[AttachService('createUserRequest')]
protected CreateUserRequest $createUserRequest;
public function store(): string
{
if (!$this->createUserRequest->isValid()) {
// ...
}
return '';
}
}The container resolves each service once per request, so the Dto is
built exactly once, from that request's own input — the same lifecycle as the
config, input, and output services BaseController already attaches.
orange\framework\controllers\JsonController already maps a validationFail
status to HTTP 406 in its $restSuccessMap, so a failed request reports
through the same response() helper used everywhere else:
namespace app\users\controllers;
use orange\framework\controllers\JsonController;
use app\users\requests\CreateUserRequest;
class UserApiController extends JsonController
{
public function store(): string
{
$request = new CreateUserRequest($this->input->request());
if (!$request->isValid()) {
// allErrors() over errors() so nested dto-array detail reaches
// the client instead of a single "has 1 or more errors" rollup
$this->data['errors'] = $request->allErrors();
return $this->response('validationFail'); // 406
}
// ... persist $request->asColumns(tablename: 'user') ...
$this->data = $request->asArray();
return $this->response('create'); // 201
}
}Dto subclasses have no required location — put them wherever your module
organizes its code, e.g. application/<module>/requests/ or
api/<module>/requests/, following the same HMVC layout as the rest of the
module.
The sample/ directory contains runnable request classes covering the full
attribute set — sign-up, CMS article, payment, API settings, conditional
contact preferences, and an order with nested dto-array line items
(Order / OrderLine, including extracting child errors after a failure).
Run them all against valid and invalid input:
php sample/run.phpThe package ships a PHPUnit suite under unittests/ with a phpunit.xml.dist:
composer test # run the suite
composer test-coverage # run with a text coverage report (needs Xdebug or PCOV)MIT. See LICENSE.