Field Grouping
Group related fields together and extract them as a single array using the group() method.
Basic Usage
final class SearchRequest extends Request
{
#[Field(group: 'criteria')]
public ?string $search = null;
#[Field(group: 'criteria')]
public ?string $status = null;
#[Field(group: 'pagination')]
public int $page = 1;
#[Field(group: 'pagination')]
public int $perPage = 20;
}
$dto = $handler->handleQuery(SearchRequest::class, $request);
$criteria = $dto->group('criteria');
// ['search' => '...', 'status' => '...']
$pagination = $dto->group('pagination');
// ['page' => 1, 'perPage' => 20]Flattening Behavior
The group() method returns a flat array:
- Associative array properties: Contents are merged into result
- Scalar properties and sequential arrays: Added by property name (or by
mapToif specified) - Empty arrays: Skipped entirely
final class FilterRequest extends Request
{
#[Field(group: 'criteria')]
public array $search = [];
#[Field(group: 'criteria')]
public array $filters = [];
#[Field(group: 'criteria')]
public int $limit = 10;
/** @var array<string> */
#[Field(group: 'criteria')]
public array $statuses = [];
}
// Given:
$dto->search = ['name' => ['LIKE', '%test%']];
$dto->filters = ['status' => 'active'];
$dto->limit = 20;
$dto->statuses = ['pending', 'paid'];
$criteria = $dto->group('criteria');
// Result:
// [
// 'name' => ['LIKE', '%test%'], // associative array — merged by keys
// 'status' => 'active', // associative array — merged by keys
// 'limit' => 20, // scalar — by property name
// 'statuses' => ['pending', 'paid'], // sequential array — by property name
// ]
// Note: empty arrays (e.g. $search = []) are skipped entirelyKey Remapping with mapTo
Use mapTo to change the output key in group() for scalar properties. This is useful when the PHP property name differs from the desired output key (e.g., database column names):
final class FilterRequest extends Request
{
#[Field(mapTo: 'positions.id', group: 'criteria')]
public int $position_id;
#[Field(mapTo: 'departments.name', group: 'criteria')]
public ?string $department = null;
}
$dto->position_id = 5;
$dto->department = 'Engineering';
$criteria = $dto->group('criteria');
// [
// 'positions.id' => 5, // mapped from $position_id
// 'departments.name' => 'Engineering' // mapped from $department
// ]INFO
mapTo only affects scalar properties and sequential arrays in group(). Associative array properties are always merged by their own keys. toArray() is not affected by mapTo.
Duplicate Key Protection
A LogicException is thrown if duplicate keys are detected:
final class ConflictRequest extends Request
{
#[Field(group: 'data')]
public array $first = []; // ['name' => 'Alice']
#[Field(group: 'data')]
public array $second = []; // ['name' => 'Bob']
}
$dto->group('data');
// LogicException: Duplicate key 'name' in group 'data' from property 'second'Uninitialized Properties
Only initialized properties are included in group results:
final class PartialRequest extends Request
{
#[Field(group: 'filters')]
public ?string $search = null;
#[Field(group: 'filters')]
public ?string $category; // No default, might be uninitialized
}
// If only 'search' was in request:
$dto->group('filters');
// ['search' => 'test'] // 'category' not includedNon-Existent Groups
Returns empty array for groups with no matching fields:
$dto->group('nonexistent');
// []Performance
Group metadata is cached per class. Multiple calls to group() reuse cached property lists:
// First call builds cache
$filters = $dto->group('criteria');
// Subsequent calls use cache
$filters = $dto->group('criteria');Clearing Cache
For long-running processes (Swoole, RoadRunner, Octane):
// Clear all cache
Request::clearGroupCache();
// Clear specific class
Request::clearGroupCache(SearchRequest::class);Practical Examples
Search Filters
final class ProductSearchRequest extends Request
{
#[Field(group: 'filters')]
public ?string $query = null;
#[Field(group: 'filters')]
public ?string $category = null;
#[Field(group: 'filters')]
public ?float $minPrice = null;
#[Field(group: 'filters')]
public ?float $maxPrice = null;
#[Field(group: 'sorting')]
public string $sortBy = 'created_at';
#[Field(group: 'sorting')]
public string $sortDir = 'DESC';
#[Field(group: 'pagination')]
public int $page = 1;
#[Field(group: 'pagination')]
public int $limit = 20;
}
// Usage
$filters = $dto->group('filters');
$sorting = $dto->group('sorting');
$pagination = $dto->group('pagination');
$products = $repository->search($filters, $sorting, $pagination);API Response Options
final class ApiRequest extends Request
{
#[Field(rules: 'required|integer')]
public int $resourceId;
#[Field(group: 'options')]
public bool $includeRelations = false;
#[Field(group: 'options')]
public bool $includeMeta = false;
#[Field(group: 'options')]
public ?string $fields = null;
}
$options = $dto->group('options');
// ['includeRelations' => true, 'includeMeta' => false, 'fields' => 'id,name']Query Builder Integration
final class UserListRequest extends Request
{
#[Field(group: 'where')]
public array $filters = [];
#[Field(group: 'where')]
public array $search = [];
#[Field(group: 'order')]
public string $orderBy = 'id';
#[Field(group: 'order')]
public string $orderDir = 'ASC';
}
// Build query
$query = $userRepository->query();
foreach ($dto->group('where') as $column => $value) {
$query->where($column, $value);
}
$order = $dto->group('order');
$query->orderBy($order['orderBy'], $order['orderDir']);Combining with Other Features
Groups work with all other field features:
final class ComplexRequest extends Request
{
#[Field(
rules: 'nullable|string',
preProcess: 'trim',
group: 'search'
)]
public ?string $query = null;
#[Field(
rules: 'in:asc,desc',
postProcess: 'strtoupper',
group: 'sorting'
)]
public string $direction = 'asc';
#[Field(
generator: TimestampGenerator::class,
group: 'meta',
exclude: true
)]
public int $requestedAt;
}