schranz-search/seal
Schranz Search SEAL is a PHP search engine abstraction layer that provides a unified API for Elasticsearch, OpenSearch, Meilisearch, Algolia, and other backends. Swap engines without rewriting search code, with adapters and integrations for Laravel and Symfony.
SEAL is an abstraction layer, not a search engine. You install the core package plus exactly one adapter for the engine you actually run:
composer require schranz-search/seal schranz-search/seal-elasticsearch-adapter
# or: seal-meilisearch-adapter, seal-opensearch-adapter, seal-typesense-adapter,
# seal-algolia-adapter, seal-solr-adapter, seal-redis-adapter, seal-memory-adapter
The first useful smoke test is the in-memory adapter, which lets you exercise the whole API without infrastructure:
use Schranz\Search\SEAL\Adapter\Memory\MemoryAdapter;
use Schranz\Search\SEAL\Schema\Field;
use Schranz\Search\SEAL\Schema\Schema;
use Schranz\Search\SEAL\Search\Condition\EqualCondition;
use Schranz\Search\SEAL\Search\Engine;
use Schranz\Search\SEAL\Search\Object\Object;
use Schranz\Search\SEAL\Search\Query;
use Schranz\Search\SEAL\Search\Search;
$schema = new Schema('blog', [
'id' => new Field\IdentifierField('id'),
'title' => new Field\TextField('title'),
'body' => new Field\TextField('body'),
]);
$engine = new Engine(new MemoryAdapter());
$engine->createIndex($schema);
$engine->index(
$schema,
new Object(['id' => '1', 'title' => 'Hello world', 'body' => '...']),
);
$search = new Search($engine, $schema, new Query('hello'));
$search->addFilter(new EqualCondition('id', '1'));
foreach ($search->execute() as $object) {
echo $object['title'];
}
Look first at Engine (src/Search/Engine.php) and Search (src/Search/Search.php) — they define the entire contract you will use daily. Then read src/Schema/ for field types and the adapter directory for engine-specific behaviour. The MemoryAdapter is also the best executable documentation while learning the API.
Swap adapters behind DI, not in application code. Construct the adapter once in your container and inject a single Engine. Production config points at Elasticsearch/Meilisearch; test config points at MemoryAdapter. Nothing downstream should reference an adapter class.
Treat the schema as code. Define one Schema per logical index (often per locale or per entity), keep it in version control, and manage createIndex/dropIndex from a console command or deploy step. Because mapping changes generally require a rebuild, make reindexing a first-class command: drop, recreate, replay from the database.
Wrap indexing in a small repository/service. Map your domain entities to Object instances in one place, then call index() in bulk batches and delete() on removal. Trigger indexing from domain events or an outbox/queue rather than inside request handlers, so a slow engine never blocks a write.
Build searches with a query builder, not ad-hoc arrays. A typical controller-facing service accepts a search term, filter values, sort key, and page number, then assembles a Search:
$search = new Search($engine, $schema, new Query($term));
$search->addFilter(new EqualCondition('status', 'published'));
$search->addSort(new Sort('published_at', SortOrder::DESC));
$search->setLimit(20)->setOffset(($page - 1) * 20);
Multiple addFilter() calls combine as AND; use AndCondition/OrCondition for nested logic. Add aggregations when rendering facet UIs and read them from the Result.
Hydrate results by ID. Adapters return Object values, not your entities. Collect IDs from the Result, fetch the rows from your database in one query, and optionally use getTotal() for pagination totals. This keeps the search index a projection rather than a source of truth.
Prefer the Symfony bundle when applicable. schranz-search/seal-bundle gives you YAML-configured adapters and schemas plus autowiring for Engine and named search services — far less boilerplate than assembling adapters manually.
schranz-search/search monorepo; don't expect fixes here.Object; most engines expect string-compatible IDs even if your database uses integers.LikeCondition translation, highlighting, and aggregation support vary by adapter. Test the queries you actually rely on against the real engine, not just the memory adapter.Engine accepts a PSR-3 logger; enable it to see the requests each adapter emits and to diagnose unexpectedly empty results.search_after-style strategies or capping result windows.getTotal() may cost an extra query depending on the adapter. Call it only when you render pagination.How can I help you explore Laravel packages today?