Field Grouping
Group related fields with #[Group] and extract them as a single array using the group() method.
Basic Usage
use Solo\RequestHandler\Attributes\Group;
final class SearchRequest extends Request
{
#[Group('criteria')]
public ?string $search = null;
#[Group('criteria')]
public ?string $status = null;
#[Group('pagination')]
public int $page = 1;
#[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
{
#[Group('criteria')]
public array $search = [];
#[Group('criteria')]
public array $filters = [];
#[Group('criteria')]
public int $limit = 10;
/** @var array<string> */
#[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 the mapTo: named argument on #[Group] to change the output key for scalar properties. Useful when the PHP property name differs from the desired output key (e.g., database column names):
final class FilterRequest extends Request
{
#[Group('criteria', mapTo: 'positions.id')]
public int $position_id;
#[Group('criteria', mapTo: 'departments.name')]
public ?string $department = null;
}
$dto->position_id = 5;
$dto->department = 'Engineering';
$criteria = $dto->group('criteria');
// [
// 'positions.id' => 5,
// 'departments.name' => 'Engineering'
// ]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
{
#[Group('data')]
public array $first = [];
#[Group('data')]
public array $second = [];
}
$dto->first = ['name' => 'Alice'];
$dto->second = ['name' => 'Bob'];
$dto->group('data');
// LogicException: Duplicate key 'name' in group 'data' from property 'second'Logical Connector Keys (AND / OR)
The keys AND and OR are exempt from duplicate protection — they are logical group wrappers in the criteria DSL, and more than one grouped property may legitimately contribute one (e.g. a multi-word search and a stock filter both emitting an AND group). On a collision the second group folds into an integer-keyed list entry, so the criteria builder AND-combines both:
$dto->search = ['AND' => [['OR' => ['name' => ['LIKE', '%foo%']]]]];
$dto->inStock = ['AND' => [['OR' => ['stock' => ['>', 0]]]]];
$dto->group('criteria');
// [
// 'AND' => [['OR' => ['name' => ['LIKE', '%foo%']]]],
// 0 => ['AND' => [['OR' => ['stock' => ['>', 0]]]]],
// ]Duplicate leaf/column keys still throw — that remains a real conflict.
Uninitialized Properties
Only initialized properties are included in group results:
final class PartialRequest extends Request
{
#[Group('filters')]
public ?string $search = null;
#[Group('filters')]
public ?string $category;
}
// 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:
$filters = $dto->group('criteria');
$filters = $dto->group('criteria'); // reuses cacheClearing Cache
Request::clearCache() is provided as a test helper for isolating cases within a single PHP process. Under PHP-FPM you don't need to call it — the cache dies with the request.
Request::clearCache();
Request::clearCache(SearchRequest::class);Practical Examples
Search Filters
final class ProductSearchRequest extends Request
{
#[Group('filters')]
public ?string $query = null;
#[Group('filters')]
public ?string $category = null;
#[Group('filters')]
public ?float $minPrice = null;
#[Group('filters')]
public ?float $maxPrice = null;
#[Group('sorting')]
public string $sortBy = 'created_at';
#[Group('sorting')]
public string $sortDir = 'DESC';
#[Group('pagination')]
public int $page = 1;
#[Group('pagination')]
public int $limit = 20;
}
$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
{
#[Validate('required|integer')]
public int $resourceId;
#[Group('options')]
public bool $includeRelations = false;
#[Group('options')]
public bool $includeMeta = false;
#[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
{
#[Group('where')]
public array $filters = [];
#[Group('where')]
public array $search = [];
#[Group('order')]
public string $orderBy = 'id';
#[Group('order')]
public string $orderDir = 'ASC';
}
$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 Attributes
Groups compose with the rest of the attribute family:
use Solo\RequestHandler\Attributes\{Validate, PreProcess, PostProcess, Generator, Group, Exclude};
final class ComplexRequest extends Request
{
#[Validate('nullable|string')]
#[PreProcess('trim')]
#[Group('search')]
public ?string $query = null;
#[Validate('in:asc,desc')]
#[PostProcess('strtoupper')]
#[Group('sorting')]
public string $direction = 'asc';
#[Generator(TimestampGenerator::class)]
#[Group('meta')]
#[Exclude]
public int $requestedAt;
}