Search by

yuriitatur / search

yuriitatur

A simple framework/db agnostic api search package

Package info

bitbucket.org/yurii_tatur/search

pkg:composer/yuriitatur/search

Statistics

Installs: 36

Dependents: 2

Suggesters: 0

v1.1.0 2026-10-31 23:52 UTC

This package is auto-updated.

Last update: 2026-10-31 23:53:14 UTC


README

Quality Gate Status Coverage

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:

KeyFilterBehaviour
limitLimitFilterSets result limit (default 100)
paginationPaginationFilterOffset-based page navigation; pages by the limit already on the query
allNoLimitRemoves the limit
cursorCursorFilterEnables cursor-based pagination
idPositiveIntegerFilterFilters by a single positive integer id
idsInConcatenatedFilterFilters by a comma-separated list of ids
sortMultiValueFilter(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 field
  • EnumFilter($field, array $choices) — restricts a field to a fixed list of string values
  • BooleanFilter($field) — accepts 1/0, on/off, true/false and filters by a boolean
  • LimitedStringFilter($field, $maxLength = 255) — string equality with a length cap
  • AllowedOrderByFilter(array $fields) — sorting restricted to a whitelist of fields (see Sorting)
  • CompositeFilter(array $filters) — groups multiple filters under a single key
  • MultiValueFilter($filter) — applies the inner filter to every item of an array value
  • ConcatenatedMultiValueFilter($filter, $delimiter) — splits a string on $delimiter and 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:

RequestMeaning
?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:

ClassRequires
PropertyAccessCursorHydratorsymfony/property-access
SymfonySerializerCursorHydratorsymfony/serializer
JmsSerializerCursorHydratorjms/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.