Field Grouping
Group related fields with #[Group] and extract them as a single array with Inputs::group().
Basic Usage
use Solo\Input\Attributes\{Group, Input};
use Solo\Input\Inputs;
#[Input]
final readonly class SearchInput
{
public function __construct(
#[Group('criteria')]
public ?string $search = null,
#[Group('criteria')]
public ?string $status = null,
#[Group('pagination')]
public int $page = 1,
#[Group('pagination')]
public int $perPage = 20,
) {
}
}
$input = $factory->fromQuery(SearchInput::class, $request);
$criteria = Inputs::group($input, 'criteria');
// ['search' => '...', 'status' => '...']
$pagination = Inputs::group($input, 'pagination');
// ['page' => 1, 'perPage' => 20]Flattening Behavior
Inputs::group() reads the group's fields in declaration order and returns a flat array:
- Associative array fields: Contents are merged by their own keys, preserving their order and nested values
- Other values — scalars, sequential arrays and objects: Added by field name (or by
mapToif specified) - Empty arrays and
nullvalues: Skipped entirely false,0and"": Included as ordinary values
Only the field's own null value is skipped. A null entry inside an associative array is preserved and its key participates in duplicate detection.
Only arrays are merged. An object is one value even when it is iterable: a date, a value object or a repository filter is added under the field name like a scalar.
#[Input]
final readonly class FilterInput
{
/**
* @param array<string, mixed> $search
* @param array<string, mixed> $filters
* @param list<string> $statuses
*/
public function __construct(
#[Group('criteria')]
public array $search = [],
#[Group('criteria')]
public array $filters = [],
#[Group('criteria')]
public int $limit = 10,
#[Group('criteria')]
public array $statuses = [],
) {
}
}
$input = new FilterInput(
search: ['name LIKE' => '%test%'],
filters: ['status' => 'active'],
limit: 20,
statuses: ['pending', 'paid'],
);
Inputs::group($input, 'criteria');
// [
// 'name LIKE' => '%test%', // associative array — merged by keys
// 'status' => 'active', // associative array — merged by keys
// 'limit' => 20, // scalar — by field name
// 'statuses' => ['pending', 'paid'], // sequential array — by field name
// ]
// An empty array (search: []) would be skipped entirelyKey Remapping with mapTo
Use the mapTo: named argument on #[Group] to change the output key. Useful when the field name differs from the desired output key — a database column name, or a criteria key that also carries an operator:
#[Input]
final readonly class ProductFilterInput
{
/**
* @param list<string> $statuses
*/
public function __construct(
#[Validate('integer')]
#[Group('criteria', mapTo: 'category_id')]
public ?int $category = null,
#[Validate('numeric')]
#[Group('criteria', mapTo: 'price >=')]
public ?string $min_price = null,
#[Validate('array')]
#[Group('criteria', mapTo: 'status IN')]
public array $statuses = [],
) {
}
}
// ?category=5&min_price=50.00&statuses[]=active&statuses[]=draft
Inputs::group($input, 'criteria');
// [
// 'category_id' => 5,
// 'price >=' => '50.00',
// 'status IN' => ['active', 'draft'],
// ]The key is a fixed string in your code: the client chooses values, never keys. See Repository Criteria.
INFO
mapTo affects every value added by field name — scalars, sequential arrays and objects. Associative array fields are always merged by their own keys. Inputs::toArray() is not affected by mapTo.
Duplicate Key Protection
A LogicException is thrown if duplicate keys are detected:
#[Input]
final readonly class ConflictInput
{
/**
* @param array<string, mixed> $first
* @param array<string, mixed> $second
*/
public function __construct(
#[Group('data')]
public array $first = [],
#[Group('data')]
public array $second = [],
) {
}
}
Inputs::group(new ConflictInput(first: ['name' => 'Alice'], second: ['name' => 'Bob']), 'data');
// LogicException: Duplicate key 'name' in group 'data' from field 'second'No key is exempt. Two conditions on one field differ in their keys — price >= and price <= — and do not collide.
Defaults Belong to the Group
A field's default is its value like any other: a group includes it unless it is null or an empty array. Fields do not have to be listed in Inputs::provided() to belong to a group.
// ?search=test — `page` keeps its default
Inputs::group($input, 'pagination');
// ['page' => 1, 'perPage' => 20]Non-Existent Groups
Returns empty array for groups with no matching fields:
Inputs::group($input, 'nonexistent');
// []Repository Criteria
group() belongs to no particular repository: it collects values. Solo Repository criteria are an associative array of 'field operator' keys combined with AND, plus Filter objects for what one key cannot say — OR groups, negation and relation predicates. A criteria group feeds them like this:
- A property holding one condition keeps its name for equality or takes a fixed key from
mapTo:'price >=','status IN'. - A processor producing several conditions returns them as an associative array:
['created_at >=' => $from, 'created_at <=' => $to]. - A processor producing an OR group, a negation or a relation predicate returns a
Filter, such asFilter::related('tags', ['id' => $id]). The property is typed?Filter, and the filter is added under the property name.
use Solo\Repository\Filter;
use Solo\Input\Attributes\{Caster, Group, Validate};
use Solo\Input\Contracts\CasterInterface;
use Solo\Input\ProcessContext;
use Solo\Input\Attributes\Input;
/**
* Every word must match one of the configured fields.
*
* @implements CasterInterface<Filter|null>
*/
final class KeywordSearch implements CasterInterface
{
public function cast(mixed $value, ProcessContext $context): ?Filter
{
$words = preg_split('/\s+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($words === []) {
return null;
}
return Filter::all(...array_map(
static fn(string $word): Filter => Filter::any(...array_map(
static fn(string $field): array => [$field . ' LIKE' => "%{$word}%"],
$context->config['fields'],
)),
$words,
));
}
}
#[Input]
final class OrderIndexInput
{
public function __construct(
#[Validate('integer')]
#[Group('criteria')]
public int $supplier_id,
/** @var list<string> */
#[Validate('array')]
#[Group('criteria', mapTo: 'status IN')]
public array $status = [],
#[Validate('string|max:255')]
#[Caster(KeywordSearch::class, config: ['fields' => ['number', 'note']])]
#[Group('criteria')]
public ?Filter $q = null,
) {
}
}
// ?supplier_id=7&status[]=paid&q=chair
Inputs::group($dto, 'criteria');
// [
// 'supplier_id' => 7,
// 'status IN' => ['paid'],
// 'q' => Filter, // all(any(['number LIKE' => '%chair%'], ['note LIKE' => '%chair%']))
// ]A criteria array cannot hold a Filter, so the application joins the group before the query: the conditions stay one array, and each filter is added through Filter::all(). A filter's key in the group only names it.
use Solo\Input\Inputs;
use Solo\Repository\Filter;
final class Criteria
{
/** @return array<string, mixed>|Filter */
public static function of(object $input, string $group = 'criteria'): array|Filter
{
$conditions = [];
$filters = [];
foreach (Inputs::group($input, $group) as $key => $value) {
if ($value instanceof Filter) {
$filters[] = $value;
} else {
$conditions[$key] = $value;
}
}
return $filters === [] ? $conditions : Filter::all($conditions, ...$filters);
}
}
$orders = $repository->read()->findBy(Criteria::of($dto), ['id' => 'DESC'], limit: 20);
// Conditions the application adds join the same way:
$own = Filter::all(Criteria::of($dto), ['customer_id' => $customerId]);Keys come from code
A criteria key chooses the field and the operator, so build every key from fixed strings: mapTo, a processor's literals or its config. An associative array is merged by its keys as they are — never copy request keys into one, or filter[price >=]=0 turns an equality into a comparison. When users choose the fields to filter by, map each allowed name to a fixed key.
Solo Repository checks each value against its field type before SQL and throws InvalidArgumentException for one such as '12x' for an integer. Type filter fields like the columns they filter — int, a decimal string, a date — so malformed input is a validation error before it reaches the repository.
Performance
Group metadata is cached per class. Multiple calls to Inputs::group() reuse cached field lists, but read current DTO values on every call. Group results are not cached, and building a group does not modify the DTO:
$filters = Inputs::group($dto, 'criteria');
$filters = Inputs::group($dto, 'criteria'); // reuses cacheClearing Cache
Inputs::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.
Inputs::clearCache();
Inputs::clearCache(SearchInput::class);Practical Examples
Search Filters
#[Input]
final class ProductSearchInput
{
public function __construct(
#[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 = Inputs::group($dto, 'filters');
$sorting = Inputs::group($dto, 'sorting');
$pagination = Inputs::group($dto, 'pagination');
$products = $repository->search($filters, $sorting, $pagination);API Response Options
#[Input]
final class ApiInput
{
public function __construct(
#[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 = Inputs::group($dto, 'options');
// ['includeRelations' => true, 'includeMeta' => false, 'fields' => 'id,name']Query Builder Integration
#[Input]
final class UserListInput
{
public function __construct(
#[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 (Inputs::group($dto, 'where') as $column => $value) {
$query->where($column, $value);
}
$order = Inputs::group($dto, 'order');
$query->orderBy($order['orderBy'], $order['orderDir']);Combining with Other Attributes
Groups compose with the rest of the attribute family:
use Solo\Input\Attributes\{Validate, PostProcess, Generator, Group, Exclude};
#[Input]
final class ComplexInput
{
public function __construct(
#[Generator(TimestampGenerator::class)]
#[Group('meta')]
#[Exclude]
public int $requestedAt,
#[Validate('nullable|string')]
// Strings are auto-trimmed by default.
#[Group('search')]
public ?string $query = null,
#[Validate('in:asc,desc')]
#[PostProcess(UppercaseProcessor::class)]
#[Group('sorting')]
public string $direction = 'asc',
) {
}
}The UppercaseProcessor in the example implements the typed post-processing contract:
use Solo\Input\Contracts\PostProcessorInterface;
use Solo\Input\ProcessContext;
/** @implements PostProcessorInterface<string> */
final class UppercaseProcessor implements PostProcessorInterface
{
public function postProcess(mixed $value, ProcessContext $context): string
{
return strtoupper($value);
}
}