Skip to content

marko/search

Generic search abstraction --- add full-text search to any entity with a database driver included and support for Elasticsearch, Meilisearch, and Typesense. Search provides a driver-based architecture for querying any entity with filtering, sorting, and pagination. Entities declare their searchable fields and weights by implementing SearchableInterface. The DatabaseSearchDriver executes SQL LIKE queries across those fields. Additional drivers for Elasticsearch, Meilisearch, and Typesense can be wired in without changing application code.

Terminal window
composer require marko/search

Make any entity searchable by implementing getSearchableFields(). Return a map of field names to boost weights --- higher weights increase relevance:

use Marko\Search\Contracts\SearchableInterface;
class Post implements SearchableInterface
{
public function getSearchableFields(): array
{
return [
'title' => 2.0,
'body' => 1.0,
'tags' => 1.5,
];
}
}

Declaring Filterable, Sortable and Selectable Columns

Section titled “Declaring Filterable, Sortable and Selectable Columns”

DatabaseSearchDriver only touches columns the entity declares. By default the searchable fields are the only columns a search can filter on, sort by, or return. Implement any of three optional interfaces to widen a list --- each one replaces the default for that list, so name every column it should allow:

use Marko\Search\Contracts\FilterableInterface;
use Marko\Search\Contracts\SearchableInterface;
use Marko\Search\Contracts\SelectableInterface;
use Marko\Search\Contracts\SortableInterface;
class Post implements SearchableInterface, FilterableInterface, SortableInterface, SelectableInterface
{
public function getSearchableFields(): array
{
return ['title' => 2.0, 'body' => 1.0];
}
// Columns a SearchFilter may target
public function getFilterableFields(): array
{
return ['status', 'category', 'author_id'];
}
// Columns SearchCriteria::withSort() may name
public function getSortableFields(): array
{
return ['title', 'created_at'];
}
// Columns returned in each result row (the driver never runs SELECT *)
public function getSelectableFields(): array
{
return ['id', 'title', 'slug', 'created_at'];
}
}
InterfaceMethodDefault when not implemented
FilterableInterfacegetFilterableFields()The searchable fields
SortableInterfacegetSortableFields()The searchable fields
SelectableInterfacegetSelectableFields()The searchable fields

A filter or sort on any other column throws SearchException before any SQL runs, so mapping request parameters straight into SearchCriteria cannot filter on, sort by, or read back a column such as password_hash or reset_token. Without SelectableInterface the rows do not include the primary key, so declare id there when a listing links to each row.

SearchCriteria is an immutable value object built with a fluent interface:

use Marko\Search\Value\FilterOperator;
use Marko\Search\Value\SearchCriteria;
use Marko\Search\Value\SearchFilter;
$criteria = SearchCriteria::create('php tutorial')
->withFilter(new SearchFilter(
field: 'status',
operator: FilterOperator::Equals,
value: 'published',
))
->withFilter(new SearchFilter(
field: 'category',
operator: FilterOperator::In,
value: ['php', 'backend'],
))
->withSort('created_at', 'desc')
->withPage(2)
->withPerPage(10);

Instantiate DatabaseSearchDriver with a database connection, table name, searchable entity, and SearchConfig (inject it, or build it from the ConfigRepositoryInterface), then call search():

use Marko\Search\Config\SearchConfig;
use Marko\Search\Driver\DatabaseSearchDriver;
use Marko\Search\Value\SearchCriteria;
$driver = new DatabaseSearchDriver(
connection: $connection,
tableName: 'posts',
searchable: new Post(),
config: $searchConfig,
);
$result = $driver->search(
query: 'php tutorial',
criteria: $criteria,
);

SearchResult provides total count and pagination metadata:

if ($result->isEmpty()) {
// No results found
}
foreach ($result->items as $row) {
echo $row['title'];
}
echo "Page {$result->page} of {$result->totalPages()}";
echo "Showing {$result->perPage} of {$result->total} total results";
use Marko\Search\Value\FilterOperator;
use Marko\Search\Value\SearchFilter;
// Exact match
new SearchFilter('status', FilterOperator::Equals, 'published');
// Exclude a value
new SearchFilter('status', FilterOperator::NotEquals, 'draft');
// Numeric comparisons
new SearchFilter('view_count', FilterOperator::GreaterThan, 100);
new SearchFilter('price', FilterOperator::LessThan, 50.00);
// Match against a list
new SearchFilter('category', FilterOperator::In, ['php', 'backend']);
// Partial match (pass the % wildcards yourself)
new SearchFilter('title', FilterOperator::Like, '%tutorial%');

An In filter with an empty list matches no rows. The Like filter’s value is used as a pattern as-is, so build it in code rather than passing raw user input; the search query itself is always matched literally (see below).

The search query is matched literally: %, _ and the escape character ! are escaped (with ESCAPE '!', which every supported database spells the same way), so searching for 100% finds rows containing 100% rather than every row.

The page must be 1 or greater; withPage(0) throws SearchException. The per-page count is clamped to between 1 and max_per_page, and SearchResult::$perPage reports the clamped value.

config/search.php:

return [
// DatabaseSearchDriver clamps SearchCriteria::$perPage to between 1 and this value.
'max_per_page' => 100,
];

Swap the search driver via a Preference without changing call sites:

use Marko\Core\Attributes\Preference;
use Marko\Search\Contracts\SearchInterface;
use Marko\Search\Value\SearchCriteria;
use Marko\Search\Value\SearchResult;
#[Preference(replaces: SearchInterface::class)]
class MeilisearchDriver implements SearchInterface
{
public function search(
string $query,
SearchCriteria $criteria,
): SearchResult {
// Meilisearch implementation
}
}
use Marko\Search\Contracts\SearchInterface;
use Marko\Search\Value\SearchCriteria;
use Marko\Search\Value\SearchResult;
public function search(
string $query,
SearchCriteria $criteria,
): SearchResult;
use Marko\Search\Contracts\SearchableInterface;
/** @return array<string, float> field => weight */
public function getSearchableFields(): array;

FilterableInterface, SortableInterface, SelectableInterface

Section titled “FilterableInterface, SortableInterface, SelectableInterface”
use Marko\Search\Contracts\FilterableInterface;
use Marko\Search\Contracts\SelectableInterface;
use Marko\Search\Contracts\SortableInterface;
/** @return array<string> */
public function getFilterableFields(): array; // FilterableInterface
/** @return array<string> */
public function getSortableFields(): array; // SortableInterface
/** @return array<string> */
public function getSelectableFields(): array; // SelectableInterface
use Marko\Search\Config\SearchConfig;
public function maxPerPage(): int; // search.max_per_page
public function clampPerPage(int $requested): int; // between 1 and maxPerPage()
use Marko\Search\Value\SearchCriteria;
use Marko\Search\Value\SearchFilter;
public static function create(string $query = ''): static;
public function withFilter(SearchFilter $filter): static;
public function withSort(string $field, string $direction = 'asc'): static;
public function withPage(int $page): static;
public function withPerPage(int $perPage): static;
// Properties (readonly)
public string $query;
public array $filters; // SearchFilter[]
public string $sortBy;
public string $sortDirection;
public int $page;
public int $perPage;
use Marko\Search\Value\FilterOperator;
use Marko\Search\Value\SearchFilter;
public function __construct(
public string $field,
public FilterOperator $operator,
public mixed $value,
);
CaseValueDescription
EqualsequalsExact match (=)
NotEqualsnot_equalsExclude value (!=)
GreaterThangreater_thanNumeric greater than (>)
LessThanless_thanNumeric less than (<)
IninMatch any value in a list (IN)
LikelikePartial string match (LIKE)
use Marko\Search\Value\SearchResult;
public function __construct(
public array $items,
public int $total,
public string $query,
public int $page,
public int $perPage,
);
public function totalPages(): int;
public function isEmpty(): bool;
use Marko\Database\Connection\ConnectionInterface;
use Marko\Search\Config\SearchConfig;
use Marko\Search\Contracts\SearchableInterface;
use Marko\Search\Driver\DatabaseSearchDriver;
public function __construct(
private readonly ConnectionInterface $connection,
private readonly string $tableName,
private readonly SearchableInterface $searchable,
private readonly SearchConfig $config,
);

Filter fields must be filterable and the sort column sortable, and each row returns only the selectable columns (see Declaring Filterable, Sortable and Selectable Columns). The table name, every searchable, selectable and filter field, and the sort column are quoted through ConnectionInterface::quoteIdentifier(), so columns named with reserved words (key, group, order) work on MySQL, MariaDB and PostgreSQL, and a mixed-case PostgreSQL column (displayName) is matched exactly instead of being folded to lower case. Each name must still be a plain identifier (letters, digits and underscores, not starting with a digit): anything else, such as posts.title or id; DROP TABLE users, throws SearchException before any SQL runs. The sort direction must be asc or desc, and every search term and filter value is bound, never interpolated.

The driver uses LIKE, so searchable fields must be text columns; PostgreSQL has no LIKE on integer columns. Filterable and sortable columns can be any type.

ExceptionDescription
SearchExceptionBase exception for all search errors --- includes getContext() and getSuggestion() methods. Thrown for an invalid identifier or sort direction, a filter on a column that is not filterable, a sort by a column that is not sortable, and a page below 1