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,
];
}
}

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, and searchable entity, then call search():

use Marko\Search\Driver\DatabaseSearchDriver;
use Marko\Search\Value\SearchCriteria;
$driver = new DatabaseSearchDriver(
connection: $connection,
tableName: 'posts',
searchable: new Post(),
);
$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%');

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;
use Marko\Search\Value\SearchCriteria;
use Marko\Search\Value\SearchFilter;
public static function create(string $query = ''): static;
public function withFilter(SearchFilter $searchFilter): 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\Contracts\ConnectionInterface;
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,
);
ExceptionDescription
SearchExceptionBase exception for all search errors --- includes getContext() and getSuggestion() methods