yuriitatur / search
A simple framework/db agnostic api search package
Requires
- php: >=8.4
- illuminate/collections: ^12.28 || ^13.0
- symfony/cache-contracts: ^3.6
- symfony/validator: ^7.3 || ^8.0
- yuriitatur/exceptions: ^1.0
- yuriitatur/query-builder: dev-master
- yuriitatur/repository: dev-master
Requires (Dev)
- dg/bypass-finals: ^1.9
- jms/serializer: ^3.32
- kint-php/kint: ^6.0
- league/fractal: ^0.20.2
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^13.0
- symfony/property-access: ^8.0
- symfony/serializer: ^8.0
- yuriitatur/repository-laravel: dev-master
Suggests
- jms/serializer: Allows you to use JmsSerializerCursorHydrator
- symfony/cache: A TagAwareCacheInterface implementation for CursorCache outside Laravel
- symfony/property-access: Required by PropertyAccessCursorHydrator, the default cursor hydrator of the Laravel integration
- symfony/serializer: Allows you to use SymfonySerializerCursorHydrator
- yuriitatur/repository-laravel: Laravel repositories; its event dispatcher is what clears cached cursors on writes
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-31 23:53:14 UTC
README
Search module
A framework-agnostic PHP 8.4+ library for building filterable, sortable, and paginated API queries. It validates incoming request options, composes a QueryBuilder, and executes the query — all driven by a set of composable filters you define.
Installation
composer require yuriitatur/search
Core concepts
Seeker
Seeker is the main entry point. It wires together a validator, a query composer, an executor, and a map of filters:
$seeker = new Seeker(
new RequestValidator(Validation::createValidator()),
new QueryComposer,
new PaginatorQueryExecutor($repository),
DefaultFilters::get()
);
$result = $seeker->seek($request->query->all());
seek() validates the options against the registered filters, builds a QueryBuilder, executes it, and returns an IteratorAggregate result.
Filters
Filters implement FilterInterface:
interface FilterInterface
{
public function applyFilter(QueryBuilder $builder, mixed $value): void;
/** @return Constraint[] */
public function getValidationRules(): array;
}
Each filter both mutates the query and declares its own Symfony Validator constraints. If any option fails validation a ValidationException is thrown before the query is built.
Built-in filters
DefaultFilters::get(array $mergeFilters = [], int $defaultLimit = 100) returns a ready-made filter map:
| Key | Filter | Behaviour |
|---|---|---|
limit | LimitFilter | Sets result limit (default 100) |
pagination | PaginationFilter | Offset-based page navigation; pages by the limit already on the query |
all | NoLimit | Removes the limit |
cursor | CursorFilter | Enables cursor-based pagination |
id | PositiveIntegerFilter | Filters by a single positive integer id |
ids | InConcatenatedFilter | Filters by a comma-separated list of ids |
sort | MultiValueFilter(OrderByFilter) | Accepts one or more column:asc\|desc values |
Additional filters available to use directly:
LikeFilter($field, $minLength = 4, $not = false)— full-text LIKE search on a fieldEnumFilter($field, array $choices)— restricts a field to a fixed list of string valuesBooleanFilter($field)— accepts1/0,on/off,true/falseand filters by a booleanLimitedStringFilter($field, $maxLength = 255)— string equality with a length capAllowedOrderByFilter(array $fields)— sorting restricted to a whitelist of fields (see Sorting)CompositeFilter(array $filters)— groups multiple filters under a single keyMultiValueFilter($filter)— applies the inner filter to every item of an array valueConcatenatedMultiValueFilter($filter, $delimiter)— splits a string on$delimiterand applies the inner filter to every part; each part is validated with the inner filter's rules
Pass any overrides or additions as the first argument to DefaultFilters::get():
DefaultFilters::get([
'name' => new LikeFilter('name'),
'status' => new EnumFilter('status', array_column(StatusEnum::cases(), 'value')),
])
Sorting
The sort key accepts column:asc or column:desc. Pass multiple values as an array for multi-column sorting:
$seeker->seek(['sort' => ['created_at:desc', 'name:asc']]);
The default OrderByFilter accepts any column name. To expose only some columns — and to decouple the public name from the entity field — use AllowedOrderByFilter. The keys are the names clients send, the values are the entity fields the query is sorted by:
DefaultFilters::get([
'sort' => new MultiValueFilter(new AllowedOrderByFilter([
'name' => 'name',
'created' => 'createdAt',
])),
]);
$seeker->seek(['sort' => ['created:desc']]); // ORDER BY createdAt DESC
$seeker->seek(['sort' => ['password:asc']]); // ValidationException ("Sort by one of: name, created")
Directions are lowercase only (asc/desc), and an empty whitelist throws InvalidArgumentException. For a single-string form such as ?sort=created:desc,name:asc, wrap it in ConcatenatedMultiValueFilter with ',' as the delimiter instead.
QueryComposer defaults
QueryComposer applies a default limit (100), offset (0), and sort (id desc) when those are not set by any filter. Override via constructor:
new QueryComposer(defaultLimit: 50, defaultSortColumn: 'created_at', defaultSortDirection: 'asc')
Executors
Offset pagination — PaginatorQueryExecutor
Returns a PaginatorResult with data, totalCount, and helpers like getCurrentPage(), getLastPage(), getPerPage(). limit=0 returns no rows, only the total — per_page and last_page are then 0.
$executor = new PaginatorQueryExecutor($repository);
Cursor pagination — CursorQueryExecutor
Returns a CursorResult with data, currentCursor, nextCursor, and previousCursor. Cursor values are opaque strings. The previous cursor is computed lazily and cached.
$executor = new CursorQueryExecutor($repository, $cache, $cursorQueryBuilder, $extractor);
Clients send the cursor back as the cursor option:
| Request | Meaning |
|---|---|
?cursor= (empty) | First page |
?cursor=<next> | Page after the current one |
?cursor=<prev> | Page before the current one |
prev is null when there is no previous page, and "" when the previous page is the first one. Either way, sending it back as ?cursor=<prev> does the right thing. next is set whenever the current page has rows, so the end of the data is reached when a page comes back empty.
Auto-routing — CombinedExecutor
Routes to CursorQueryExecutor when a cursor option is present in the query, otherwise falls back to PaginatorQueryExecutor. This lets a single endpoint support both pagination styles.
$executor = new CombinedExecutor($paginatorExecutor, $cursorExecutor);
CursorResult and PaginatorResult both implement IteratorAggregate, so the result can always be iterated directly regardless of which executor was used.
Cursor hydrators
CursorQueryExecutor needs to extract the sort-column value from result entities. Three hydrator implementations are provided:
| Class | Requires |
|---|---|
PropertyAccessCursorHydrator | symfony/property-access |
SymfonySerializerCursorHydrator | symfony/serializer |
JmsSerializerCursorHydrator | jms/serializer |
Laravel integration
The service provider and the Search facade are auto-discovered. Install yuriitatur/repository-laravel for repositories, and symfony/property-access for the default cursor hydrator. Publish the config to change the defaults:
php artisan vendor:publish --tag=search-config
config/search.php sets the default limit and sort, the page and cursor query keys, the cursor hydrator, the cache store and TTL for cursors, and whether results come back as Laravel paginators.
Searching
use YuriiTatur\Search\Laravel\Facades\Search;
public function index()
{
return UserResource::collection(
Search::for(UserRepository::class) // or a repository instance
->filters(['email' => new LikeFilter('email'), 'ids' => null]) // merged over the defaults, null removes one
->seek() // the current request's query string
);
}
SearchBuilder is immutable; every method returns a copy. It also has withoutDefaultFilters(), baseQuery(), defaultLimit(), defaultSort(), laravelPaginators(false), validate() and seeker(). Name a search with as('users') when its repository is generic (an EntityRepository from EloquentRepositoryBuilder, or a decorator): cached cursors are keyed by it.
Results are a LengthAwarePaginator (?page=) or a SeekerCursorPaginator (?cursor=), so API resources return the usual links and meta. Page links keep the other query parameters and drop offset.
Form requests
php artisan make:search-request UserSearchRequest
final class UserSearchRequest extends SearchRequest
{
protected function search(SearchBuilder $search): SearchBuilder
{
return $search->for(UserRepository::class)->filters(['email' => new LikeFilter('email')]);
}
}
public function index(UserSearchRequest $request)
{
return UserResource::collection($request->seek());
}
Filters are validated when the request is resolved, before the controller runs.
Controller injection
public function index(
Request $request,
#[Searchable(UserRepository::class, filters: ['email' => new LikeFilter('email')])] Seeker $users,
) {
return UserResource::collection($request->seek($users));
}
Type the parameter SearchBuilder instead to configure it further. $request->seek() is a request macro that seeks with the query string.
Cursor cache
Previous-page cursors are cached in a Laravel cache store. Any driver works, tags are not needed. Saving or deleting an entity through a repository drops the cursors computed from that entity class; bulk updates and deletes drop all of them. Turn this off with search.cursor.cache.invalidate_on_write.
Testing
$fake = Search::fake([['id' => 1]], total: 1); // every search returns these items
$this->getJson('/users?email=john')->assertOk();
$fake->assertSearched(fn (QueryBuilder $query) => str_contains((string)$query, 'john'));
$fake->assertSearchedTimes(1);
Validation and filters still run; only the repository is replaced.
Generators
php artisan make:search-filter StatusFilter creates a filter in app/Search/Filters.
Octane
Filters keep no state between requests (the page size is read from the query, not stored in LimitFilter), and SearchBuilder is built per resolution, so searches are safe under Octane and in queue workers.
Testing
composer test
License
This code is under MIT license, read more in the LICENSE file.