Omnisearch is search the way Doctrine is for SQL, whatever the engine. A class declares its index
with attributes (#[Index], #[Field]), a manager hands out one finder per index, a query is
written once with a fluent Query, and the objects come back in the order of the hits, each with
the words it matched. Under that ORM sits a DBAL: connections, drivers, requests and responses, the
part an engine's package implements.
use Omnisearch\Mapping\{Field, Index, Type};
use Omnisearch\ORM\Query;
#[Index('article')]
#[Field('title', type: Type::STRINGS, property: 'translations.title')]
#[Field('tags', type: Type::STRINGS, property: 'tags.slug', facet: true)]
#[Field('publishedAt', type: Type::DATETIME, sort: true)]
class Article { /* an entity that implements nothing of omnisearch */ }
$result = $manager->finder(Article::class)->search(
(new Query('fuji'))->in('title')->where('tags', 'en-musique')->facet('tags')->sortBy('publishedAt', 'desc')->perPage(10)
);
$result->objects(); // Article entities, in the order of the hits
$result->hit($result->objects()[0])->matchedTokens(); // the words it matched
$result->facets(checked: ['tags' => ['en-musique']]); // each tag, its count, checked or not
The core requires PHP 8.2 and nothing else. Doctrine and Symfony are suggested, never required:
their bridges (Omnisearch\Bridge\Doctrine, Omnisearch\Bridge\Symfony) are the only classes
that load them. The shape comes from glitchr/typesense-bundle, whose request/response and
manager/finder/mapping/transformer it generalises to any engine: what the bundle's YAML said of a
collection, an attribute says on the class.
Published as omnisense/omnisearch, formerly omniscient/omnisearch: the package replaces the
old name. The namespace is Omnisearch\; engines keep the omnisearch/ vendor and the
Omnisearch\<Engine>\ namespace.
When to use it
- An application that searches its own objects (articles, products, people) with full-text words, filters, facets and pages, and wants the objects back rather than raw documents.
- Code that should not be tied to one engine: the mapping, the queries and the results are the
same for each; only the connection's
driverchanges. - Tests and first steps without a server: the memory engine runs the same queries in the PHP process.
It is not a search engine itself (the memory engine keeps documents in an array, for tests), and it does not replace an engine's own tools: Typesense's collections, imports and listener stay glitchr/typesense-bundle's in a Symfony application (Symfony).
Engines
| Package | Driver name | Engine | Notes |
|---|---|---|---|
omnisearch/typesense |
typesense |
Typesense, over glitchr/typesense-bundle's framework-free part | infix search, matched tokens, facets, discriminator sub-collections; objects must be Doctrine entities; in Symfony the bundle hosts the indexes |
Omnisearch\Memory (in this package) |
memory |
the PHP process | prefix and infix matching, filters, facets, sort, pages; nothing outlives the process |
What every driver does is the Omnisearch\DBAL\Driver contract, checked by the conformance suite
(Testing).
The layers
| Namespace | |
|---|---|
Omnisearch\Mapping |
#[Index], #[Field], Type; AttributeLoader (reflection), ClassMetadata, FieldMetadata, MetadataRegistry |
Omnisearch\DBAL |
Driver (what an engine does), DriverFactory, Connection, Request, Filter, Response, Hit, Facet, Batch (writes gathered, sent together) |
Omnisearch\ORM |
Manager, Finder, Query, Result, Indexer (what to index on persist, update, remove), Transformer (object to document), PropertyReader, ObjectStore (where the objects live) |
Omnisearch\Registry |
the connections by name, from their configuration |
Omnisearch\Memory |
the memory engine: MemoryDriver, MemoryDriverFactory, Identity |
Omnisearch\Bridge\Doctrine |
DoctrineObjectStore (entities identified, loaded back in the hits' order), IndexListener (the indexing rule on Doctrine's events) |
Omnisearch\Bridge\Symfony |
OmnisearchBundle, its two commands, Host (an engine's own bundle hosting its indexes) |
Omnisearch\Testing |
DriverContractTests, ContractDocument: the conformance suite |
Omnisearch\Exception |
OmnisearchException and its three subclasses |
How this manual is organised
- Installation: requirements, a whole script in plain PHP, Doctrine and Typesense.
- Symfony and Laravel: wiring omnisearch in a framework.
- Concepts: every class of the mapping, the DBAL and the ORM, with its members.
- Usage: mapping indexes and fields, searching, indexing.
- Configuration reference: the registry, the bundle's tree, the attributes' arguments, the memory engine.
- Errors: the exceptions, when each is thrown, and what is answered instead.
- Testing: testing an application that searches, and the conformance suite.
- The Docker harness.
- Writing a driver.
- Verified, and not.
Proposer une modification docs/index.md
Installation
composer require omnisense/omnisearch # the core and its memory engine
composer require omnisense/omnisearch omnisearch/typesense # with Typesense
Requirements
- PHP 8.2 or later.
omnisense/omnisearchrequires nothing else. - Suggested, loaded only by the bridge that needs them:
doctrine/orm(^3.0) forOmnisearch\Bridge\Doctrine;symfony/config,symfony/dependency-injection,symfony/http-kernel(^7.0 or ^8.0) for the bundle,symfony/consolefor its commands;phpunit/phpunit(^11.0) for the conformance suite. omnisearch/typesenserequiresglitchr/typesense-bundle(^2.0), whose framework-free part it uses; the bundle requiresdoctrine/orm,typesense/typesense-php(^4.0 or ^5.0),php-http/curl-clientandsymfony/cache. It indexes Doctrine entities: it needs anObjectManager(omnisearch/typesense).
Plain PHP
A whole script on the memory engine: no server, no framework, no Doctrine. The application's own
ObjectStore says what identifies a note and loads notes back from identifiers, so the search
answers notes rather than documents.
<?php // notes.php
require __DIR__.'/vendor/autoload.php';
use Omnisearch\Mapping\{ClassMetadata, Field, Index, MetadataRegistry, Type};
use Omnisearch\Memory\MemoryDriverFactory;
use Omnisearch\ORM\{Indexer, Manager, ObjectStore, Query};
use Omnisearch\Registry;
#[Index('note')]
#[Field('labels', type: Type::STRINGS, facet: true)]
final class Note
{
public function __construct(
public readonly string $id,
#[Field(infix: true)] public readonly string $text,
#[Field(type: Type::INT, sort: true)] public readonly int $rank,
public readonly array $labels = [],
) {
}
}
// Where the notes live: what identifies one, and the notes of identifiers.
final class Notes implements ObjectStore
{
/** @var array<string, Note> */
public array $notes = [];
public function identify(object $object): string
{
return $object->id;
}
public function load(ClassMetadata $metadata, array $ids, bool $cacheable = false): array
{
return array_values(array_filter(array_map(fn ($id) => $this->notes[$id] ?? null, $ids)));
}
}
$store = new Notes();
$manager = new Manager(
new Registry([new MemoryDriverFactory($store)], ['default' => ['driver' => 'memory']]),
new MetadataRegistry([Omnisearch\Mapping\AttributeLoader::load(Note::class)]),
$store,
);
$manager->create(); // every index, where it is not
$indexer = new Indexer($manager); // the rule: persisted, updated, removed
foreach ([new Note('1', 'Mount Fuji at dawn', 3, ['travel', 'japan']), new Note('2', 'Fujisan in music', 1, ['music', 'japan']), new Note('3', 'A recipe for ramen', 2, ['food'])] as $note) {
$store->notes[$note->id] = $note;
$indexer->persisted($note);
}
echo $indexer->flush(), " writes sent\n";
$result = $manager->finder(Note::class)->search((new Query('fuji'))->in('text')->facet('labels')->sortBy('rank', 'desc'));
echo $result->found(), " found\n";
foreach ($result->objects() as $note) {
printf("%s %-20s matched %s\n", $note->id, $note->text, implode(', ', $result->hit($note)->matchedTokens()));
}
foreach ($result->facets(checked: ['labels' => ['japan']])['labels']['counts'] as $value => $count) {
printf(" %-7s %d%s\n", $value, $count['count'], $count['checked'] ? ' (checked)' : '');
}
$indexer->removing($store->notes['2']); // before it goes: its identifier
$indexer->removed($store->notes['2']);
unset($store->notes['2']);
$indexer->flush();
echo implode(', ', $manager->finder('note')->raw((new Query('fuji'))->in('text'))->ids()), " left\n";
$manager->drop();
$gone = $manager->finder('note')->search(new Query('fuji'));
echo $gone->status(), ' ', $gone->error(), "\n";
$ php notes.php
3 writes sent
2 found
1 Mount Fuji at dawn matched Fuji
2 Fujisan in music matched Fujisan
japan 2 (checked)
travel 1
music 1
1 left
404 No index "note".
(run on 2026-10-26 with PHP 8.4.27, the core's checkout on the autoloader, no network)
The pieces:
- the registry (
Omnisearch\Registry): driver factories, and the connections by name, each{driver, options}; the first is the default; - the metadata (
MetadataRegistry): what the attributes declare, read once byAttributeLoader::load()orMetadataRegistry::fromDirectories([...]); - the manager (
Omnisearch\ORM\Manager):create()anddrop()the indexes,index()objects now,finder()per index; - the indexer (
Omnisearch\ORM\Indexer): the rule a persistence's events call -persisted(),updated(),removing()thenremoved(), sent onflush(); - the store (
Omnisearch\ORM\ObjectStore):identify()andload(). Without one, the memory engine identifies an object by itsgetId()or its public$id(Memory\Identity),Result::objects()is empty and only the documents come back (raw(),hits()).
Without a store, the Indexer takes a removed object's identifier the same way, so it is taken
out of its index.
Doctrine and Typesense
The same in an application with Doctrine and a Typesense server: DoctrineObjectStore identifies
entities by their identifier and loads them back in one query, IndexListener runs the rule on
Doctrine's events.
use Omnisearch\Bridge\Doctrine\DoctrineObjectStore;
use Omnisearch\Bridge\Doctrine\IndexListener;
use Omnisearch\Mapping\MetadataRegistry;
use Omnisearch\ORM\Indexer;
use Omnisearch\ORM\Manager;
use Omnisearch\ORM\Query;
use Omnisearch\Registry;
use Omnisearch\Typesense\TypesenseDriverFactory;
// $entityManager: the application's Doctrine EntityManager
$registry = new Registry([new TypesenseDriverFactory(null, $entityManager)], [
'default' => ['driver' => 'typesense', 'options' => ['url' => 'http://localhost:8108', 'secret' => 'xyz']],
]);
$manager = new Manager($registry, MetadataRegistry::fromDirectories([__DIR__.'/src/Entity']), new DoctrineObjectStore($entityManager));
$manager->create(); // every index, where it is not
$entityManager->getEventManager()->addEventListener(
IndexListener::EVENTS,
new IndexListener(new Indexer($manager, fn (\Throwable $e) => error_log($e->getMessage()))),
);
$entityManager->persist($article);
$entityManager->flush(); // indexed on postFlush
$articles = $manager->finder(Article::class)->search((new Query('fuji'))->in('title'))->objects();
The Indexer's second argument receives what an engine refuses during the flush; without it the
failure is thrown out of flush(), after the database has committed (Indexing).
docker/harness/bin/bare is a whole script of both halves, run by docker compose run --rm omnisearch bare (harness); the Typesense half was not run for this page (no
Typesense server at hand), the verified page says where it was.
In a framework
- Symfony:
Omnisearch\Bridge\Symfony\OmnisearchBundlereads the attributes when the container compiles, registers the drivers installed, a finder per index, the Doctrine listener, and hands Typesense's indexes to glitchr/typesense-bundle: Symfony. - Laravel: no bridge exists; a service provider builds the manager once: Laravel.
- Any other: build the
Manageronce, where the framework builds its services, as the scripts above do.
Proposer une modification docs/installation.md
Symfony
Omnisearch\Bridge\Symfony\OmnisearchBundle is part of this package: no other package is needed.
It requires symfony/config, symfony/dependency-injection and symfony/http-kernel (suggested by
the core, present in any Symfony application), and symfony/console for its commands.
Registration
// config/bundles.php
return [
// ...
Omnisearch\Bridge\Symfony\OmnisearchBundle::class => ['all' => true],
];
# config/packages/omnisearch.yaml
omnisearch:
connections:
default:
driver: typesense
options: { connection: default } # glitchr/typesense-bundle's connection
mappings: ['%kernel.project_dir%/src/Entity'] # the classes declaring an #[Index]; src/ by default
on_index_error: log # a write that fails during Doctrine's flush: log, throw, or a service id
use Omnisearch\ORM\Finder;
use Omnisearch\ORM\Manager;
public function __construct(private Manager $search, private Finder $blogArticle) {}
The configuration tree
As the bundle's configure() declares it (dumped with Symfony's YamlReferenceDumper, the
dumper config:dump-reference omnisearch uses):
omnisearch:
# The first connection when null.
default_connection: null
# The connections, by name: a driver (typesense, memory...) and its options.
connections:
# Prototype
name:
driver: ~ # Required
options: []
# The directories whose classes declare an #[Index]: src/ when none is given.
mappings: []
# A write an engine refuses, or cannot reach, during Doctrine's flush (after the database committed): "log" (the logger, the flush goes on), "throw" (out of EntityManager::flush()), or the id of a service called with the \Throwable.
on_index_error: log
| Key | Type | Default | |
|---|---|---|---|
default_connection |
string or null | null |
the connection of an index that names none; the first of connections when null |
connections.<name>.driver |
string | required | the driver factory's name: typesense, memory, or an application factory's |
connections.<name>.options |
any | [] |
handed whole to the factory's create(); each driver's options are in Configuration |
mappings |
list of strings | [] |
the directories scanned for #[Index]; %kernel.project_dir%/src when empty; parameters are resolved |
on_index_error |
string | log |
the Indexer's $onError: log (Bridge\Symfony\IndexErrorLogger: the logger service at error level, the exception in the context, PHP's error log without one; the flush goes on), throw (no $onError: out of EntityManager::flush()), or a service id, called with the \Throwable |
What the bundle registers
| Service | ||
|---|---|---|
Omnisearch\Mapping\MetadataRegistry |
public | built by MetadataRegistry::fromArray() from the metadata read when the container compiled |
Omnisearch\Registry |
public | the factories tagged omnisearch.driver_factory, the connections, the default_connection |
Omnisearch\ORM\Manager |
public | the registry, the metadata, and ObjectStore when DoctrineBundle is registered (none otherwise) |
omnisearch.finder.<index> |
public | Manager::finder('<index>'); also autowired as Finder $<index> (an argument named after the index) |
Omnisearch\Typesense\TypesenseDriverFactory |
private, autowired | when omnisearch/typesense is installed; given glitchr/typesense-bundle's TypesenseManager and Doctrine's ObjectManager when they exist |
Omnisearch\Memory\MemoryDriverFactory |
private, autowired | always; given the ObjectStore when there is one |
Omnisearch\Bridge\Doctrine\DoctrineObjectStore, alias Omnisearch\ORM\ObjectStore |
private | with DoctrineBundle; over the doctrine registry |
Omnisearch\ORM\Indexer, Omnisearch\Bridge\Doctrine\IndexListener, Omnisearch\Bridge\Symfony\IndexErrorLogger |
private | with DoctrineBundle, when a connection is not hosted (the logger with on_index_error: log); the listener tagged doctrine.event_listener for postPersist, postUpdate, preRemove, postRemove, postFlush |
IndexesCommand, SchemaCommand |
tagged console.command |
when symfony/console is installed |
The parameter omnisearch.hosted_connections lists the connections an engine's own bundle hosts.
Any service of the application that implements Omnisearch\DBAL\DriverFactory is tagged
omnisearch.driver_factory by autoconfiguration: a driver of the application's own is used by
naming it in driver.
The services compiled for an application with one memory connection and one #[Index('note')]
class under src/ (App\Note: text infix, labels a facet, rank sortable):
Omnisearch\Mapping\MetadataRegistry
Omnisearch\Memory\MemoryDriverFactory
Omnisearch\ORM\Manager
Omnisearch\Registry
Omnisearch\Typesense\TypesenseDriverFactory
omnisearch.finder.note
(run on 2026-10-26 with Symfony 8.1's DependencyInjection; omnisearch/typesense was installed)
The metadata at compile time
The bundle scans mappings twice - in prependExtension(), for the hosted engines, and in
loadExtension() - with AttributeLoader::scan(): only the PHP files whose text names
Omnisearch\Mapping are loaded. Each directory is a DirectoryResource of the container: in debug,
adding or changing a PHP file there recompiles it. The metadata is kept in the compiled container as
plain arrays (ClassMetadata::toArray()); nothing is read by reflection at run time.
An index name declared by two classes is a MappingException at compile time.
Hosted engines
An engine whose own bundle should create and fill its indexes is a host
(Omnisearch\Bridge\Symfony\Host). The bundle knows one: typesense, hosted by
Omnisearch\Typesense\Bridge\Symfony\TypesenseHost when Typesense\Bundle\TypesenseBundle is
registered. For each connection whose driver has a host that hosts() the application,
prependExtension() calls prepend() with the connection's options and the indexes of that
connection (an index with no connection goes to default_connection, else the first one):
- the indexes become glitchr/typesense-bundle's
mappings(prepended to its configuration), as if its YAML declared them: itstypesense:create,typesense:action,typesense:updateand its Doctrine listener serve them; - omnisearch searches through the bundle's finders, and registers no Doctrine listener of its own for that connection.
A collection still declared in the bundle's YAML lives beside them; declare a collection in one place, not both.
Indexing on Doctrine's events
With DoctrineBundle, the connections no host serves are indexed by IndexListener, which runs the
indexing rule: an entity of a declared class persisted or updated is indexed, one
removed is removed, everything sent on postFlush, consecutive writes of one index in one call.
An engine that refuses a write or cannot be reached during that flush does not stop it - the
database has committed by then: the bundle gives the Indexer the on_index_error handler, by
default IndexErrorLogger, which logs it. on_index_error: throw keeps the exception, thrown out
of EntityManager::flush().
Commands
omnisearch:indexes lists the indexes the classes declare; omnisearch:schema [index...] prints the
schema each is created with, in its engine's terms, as JSON (every index when none is named). On the
application above:
$ bin/console omnisearch:indexes
------- ---------- ------------ -------- ---------------------
Index Class Connection Driver Fields (* facet)
------- ---------- ------------ -------- ---------------------
note App\Note default memory text, labels*, rank
------- ---------- ------------ -------- ---------------------
$ bin/console omnisearch:schema note
{
"note": {
"name": "note",
"fields": [
{
"name": "text",
"type": "string",
"facet": false,
"infix": true
},
{
"name": "labels",
"type": "string[]",
"facet": true,
"infix": false
},
{
"name": "rank",
"type": "int",
"facet": false,
"infix": false
}
]
}
}
(run on 2026-10-26: the two commands on the compiled container's Manager, Symfony Console 8.1)
The memory engine's schema is its own short description; a Typesense index's is the collection's
schema as TypesenseCollection::create() sends it. Neither command calls the engine.
Without DoctrineBundle
The Manager has no store: searches answer documents (Finder::raw(), Result::hits()), and
Result::objects() is empty. No listener is registered; index with Manager::index() or an
Indexer of the application's.
Proposer une modification docs/symfony.md
Laravel
There is no Laravel bridge for omnisearch: omnibridge/laravel has none, and this package ships no
service provider. Nothing is missing for it to run, since the core needs no framework: a service
provider of the application builds the Manager once, as in plain PHP.
A service provider
namespace App\Providers;
use Doctrine\ORM\EntityManagerInterface;
use Illuminate\Support\ServiceProvider;
use Omnisearch\Bridge\Doctrine\DoctrineObjectStore;
use Omnisearch\Mapping\MetadataRegistry;
use Omnisearch\ORM\Finder;
use Omnisearch\ORM\Manager;
use Omnisearch\Registry;
use Omnisearch\Typesense\TypesenseDriverFactory;
final class SearchServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Manager::class, function ($app) {
$entities = $app->make(EntityManagerInterface::class);
return new Manager(
new Registry([new TypesenseDriverFactory(null, $entities)], [
'default' => ['driver' => 'typesense', 'options' => [
'url' => config('services.typesense.url'),
'secret' => (string) config('services.typesense.key'),
]],
]),
MetadataRegistry::fromDirectories([app_path('Entity')]),
new DoctrineObjectStore($entities),
);
});
// One index's finder where a class asks for a Finder
$this->app->when(\App\Http\Controllers\SearchController::class)
->needs(Finder::class)
->give(fn ($app) => $app->make(Manager::class)->finder(\App\Entity\Article::class));
}
}
This is the shape lesringostars' Laravel twin uses (its app/Omnibridge/ScreenServiceProvider.php):
the Symfony site's Typesense, the indexes its entities declare, Doctrine's entity manager. It needs
an EntityManagerInterface bound in the container by the application's Doctrine integration; with the memory engine and an ObjectStore of the application's, no Doctrine
is needed at all.
What a provider does not get
- The metadata at boot.
MetadataRegistry::fromDirectories()scans the directory on the first use of theManagerin each process. To skip it, keepClassMetadata::toArray()of each index in a cache file and build withMetadataRegistry::fromArray(). - Indexing on writes. Nothing listens to Doctrine's events: register
Omnisearch\Bridge\Doctrine\IndexListeneron the entity manager's event manager (IndexListener::EVENTS), with anIndexerand an$onError(Indexing). An Eloquent model is not an entity: callManager::index(), or anIndexerfrom the model's events, with anObjectStoreof the application's. - Commands.
omnisearch:indexesandomnisearch:schemaare Symfony Console commands. An Artisan command of the application prints the same fromManager::metadata()->all()andManager::connection($index)->schema($metadata), and creates or drops the indexes withManager::create()andManager::drop().
Proposer une modification docs/laravel.md
Concepts
Three layers, as in Doctrine. The mapping says what is indexed, read from attributes into plain
metadata. The DBAL is what an engine does: a driver per engine, made by its factory, behind a
named connection; a search is a Request, its answer a Response. The ORM is what the
application uses: a Manager, a Finder per index, a fluent Query, a Result with the objects
loaded back, and the Indexer that keeps the indexes in step with a persistence.
Mapping
#[Index]
Omnisearch\Mapping\Index, an attribute on a class (TARGET_CLASS, not repeatable). Its objects
are documents of one index.
| Argument | Type | Default | |
|---|---|---|---|
name |
string | required | the index's name for the application; an engine may put a prefix in front of it on its server (Typesense's prefix option) |
connection |
?string | null |
the connection it lives on; the registry's default when null |
options |
array | [] |
the engine's own settings of the index, under the engine's name (['typesense' => ['default_sorting_field' => 'publishedAt']]); a driver reads its own and ignores the rest |
#[Field]
Omnisearch\Mapping\Field, an attribute on a class, a property or a method (repeatable). One field
of the document.
| Argument | Type | Default | |
|---|---|---|---|
name |
?string | null |
the field's name in the document. Required on the class; on a property, the property's name; on a method, its name without a leading get, is or has (getReadingTime gives readingTime) |
type |
string | Type::STRING |
one of Type's constants |
property |
?string | null |
the property path the value is read from (PropertyReader); on a property or a method, that member's name; on the class, the field's name |
facet |
bool | false |
its values can be counted and filtered on |
sort |
bool | false |
results can be sorted by it |
infix |
bool | false |
it can be matched by the middle of a word, where the engine can |
optional |
bool | false |
a document may lack it (a null value is kept null, not cast) |
locale |
?string | null |
the language its text is tokenized in, for the engines that use it |
options |
array | [] |
the engine's own field settings, under the engine's name or flat |
The attributes are classes of this package and nothing else: a class carrying them loads without omnisearch installed (PHP reads an attribute's class only when asked to).
Type
Omnisearch\Mapping\Type: the field types, the same for every engine; a driver maps each onto its
own. A type ending in [] is a list (Type::isList($type)).
| Constant | Value | In a document |
|---|---|---|
STRING |
string |
a string (an object by its __toString(), null as '') |
STRINGS |
string[] |
a list of strings |
INT |
int |
an integer |
INTS |
int[] |
a list of integers |
BIGINT |
bigint |
an integer (64-bit where the engine distinguishes) |
FLOAT |
float |
a float |
FLOATS |
float[] |
a list of floats |
BOOL |
bool |
a boolean |
BOOLS |
bool[] |
a list of booleans |
DATETIME |
datetime |
a Unix timestamp (a DateTimeInterface by getTimestamp(), anything else cast to int) |
GEOPOINT |
geopoint |
[latitude, longitude], floats |
Type::ALL lists them; any other type is a MappingException when the class is loaded.
FieldMetadata and ClassMetadata
What the attributes said, as plain values (final, every property public readonly):
FieldMetadata:name,type,property,facet,sort,infix,optional,locale,options- the arguments of#[Field]once the defaults are filled.path()is the property path (property, elsename).toArray()/fromArray().ClassMetadata:class(the declaring class),index(its name),connection(?string),fields(FieldMetadataby name, in their declared order: the class's attributes first, then the properties', then the methods'),options(the index's).field($name)returns one or null,facets()the names of the facet fields.toArray()/fromArray()keep it in a compiled container or a cache file.
AttributeLoader
Omnisearch\Mapping\AttributeLoader, reflection only:
load(string $class): ?ClassMetadata- the class's#[Index]and every#[Field]; null when it declares no index. A field on the class without a name, a field declared twice, an unknown type:MappingException.scan(array $directories): array- the metadata of every class declared in the.phpfiles under these directories, by index name, sorted. A file is read as tokens and loaded only when its text namesOmnisearch\Mapping, so the rest of an application's code is not autoloaded. A directory that does not exist is skipped. Two classes declaring the same index:MappingException.
MetadataRegistry
Omnisearch\Mapping\MetadataRegistry: the application's indexes, by index name and by class.
| Method | |
|---|---|
__construct(iterable $metadata = []) |
from ClassMetadatas |
fromDirectories(array $directories) (static) |
AttributeLoader::scan() |
fromArray(array $metadata) (static) |
from ClassMetadata::toArray()s |
add(ClassMetadata $metadata) |
adds one; an index of the same name replaces the earlier one |
all() |
every ClassMetadata, by index name |
has(string $indexOrClass), find(string|object $indexOrClass), get(...) |
by index name, class name or object; an object of a sub-class belongs to its nearest declared parent's index. find() answers null, get() throws MappingException (No index for "X"; declared: a, b.) |
DBAL
Driver
Omnisearch\DBAL\Driver, the interface an engine implements, in the engine's own terms:
| Method | Contract |
|---|---|
create(ClassMetadata $metadata): void |
the index created where it is not; an existing one is left as it is |
drop(ClassMetadata $metadata): void |
the index dropped with its documents; nothing when there is none |
schema(ClassMetadata $metadata): array |
the index as create() describes it to the engine, to read or compare; calls nothing |
index(ClassMetadata $metadata, object ...$objects): void |
the objects' documents written, created or replaced |
remove(ClassMetadata $metadata, string ...$ids): void |
the documents of these identifiers removed; an unknown one is no error |
search(ClassMetadata $metadata, Request $request): Response |
an engine that cannot be reached or refuses answers a Response with its status and message, never an exception |
A write the engine refuses, or that cannot reach it, throws a DriverException
(Errors).
DriverFactory
Omnisearch\DBAL\DriverFactory: name(): string is the engine's name in a connection's
configuration (typesense, memory); create(array $options): Driver makes a driver from a
connection's options. A required option left out is an InvalidConfigException naming it.
Connection
Omnisearch\DBAL\Connection (final): a named driver. $name (public readonly), driver(), and
create(), drop(), schema(), index(), remove(), search() handed to the driver - except
that index() with no object and remove() with no identifier call nothing.
Registry
Omnisearch\Registry (final): the application's connections by name.
new Registry(iterable $factories, array $config, ?string $default = null)
// $config: ['<name>' => ['driver' => '<factory name>', 'options' => [...]], ...]
| Method | |
|---|---|
connection(?string $name = null): Connection |
made on first use by the factory named by driver, kept; the default when null. No "x" connection; configured: a, b. or No "y" driver for the "x" connection; installed: memory. as InvalidConfigException |
has(string $name): bool |
configured or not |
defaultName(): string |
$default, else the first configured name |
names(): array |
the configured names |
driverName(string $name): string |
the driver of a connection, without making it |
Request
Omnisearch\DBAL\Request (final, every property public readonly): a search, as every engine is
asked it. Query::request() builds one; a driver reads it.
| Property | Type | Default | |
|---|---|---|---|
term |
?string | null |
the words searched; null or '': every document |
fields |
list of strings | [] |
the fields the term is searched in, by importance; []: the driver's choice (memory: every field; Typesense: every string field) |
filters |
list of Filter |
[] |
all must hold |
facets |
list of strings | [] |
the fields whose values are counted |
maxFacetValues |
?int | null |
values per facet; null: the engine's default (memory: 10) |
sort |
list of [field, 'asc'|'desc'] |
[] |
in order |
page |
int | 1 |
the page, from 1 |
perPage |
?int | null |
hits per page; null: the engine's default (memory: 10) |
options |
array | [] |
what only one engine knows, by its names; a driver reads those it knows |
cacheable |
bool | false |
the answer and the objects loaded from it may be cached |
option(string $name, mixed $default = null) reads one of options.
Filter
Omnisearch\DBAL\Filter (final): a condition on a field, field, operator, value (public
readonly). On a list field, = means "holds" and != "holds not".
| Constant | Operator | Value |
|---|---|---|
EQUALS |
= |
a scalar |
NOT_EQUALS |
!= |
a scalar |
IN |
in |
a list |
NOT_IN |
not in |
a list |
LOWER |
< |
a scalar |
LOWER_OR_EQUAL |
<= |
a scalar |
GREATER |
> |
a scalar |
GREATER_OR_EQUAL |
>= |
a scalar |
Named constructors: Filter::equals(), notEquals(), in(), notIn(). An unknown operator, or
in/not in without an array, is an \InvalidArgumentException. matches(mixed $actual): bool
evaluates the filter on a document's value in PHP (the memory engine's way): = and != compare
loosely (in_array(..., false)), in and not in compare as strings, the comparisons compare
strings with strcmp() and numbers with <=>, and a list matches when one of its values does.
Response, Hit, Facet
Omnisearch\DBAL\Response (final, public readonly): what the engine answered.
| Property | Type | |
|---|---|---|
found |
int | documents found in all, every page |
page |
int | the page answered |
hits |
list of Hit |
this page's documents, in the engine's order |
facets |
array of Facet by field |
|
status |
int | 200 when the engine answered; otherwise the HTTP status (400 refused, 404 no such index, 503 or 0 not reached) |
error |
?string | the engine's message when status is not 200 |
raw |
array | the engine's own answer, without its hits |
Response::failed(int $status, string $error) builds a failed one; ok() is status === 200;
ids() the hits' identifiers.
Omnisearch\DBAL\Hit (final, public readonly): id (string), document (the fields as the
engine holds them), highlights (by field, the words of the term it matched there, in the engine's
order), score (?float: Typesense's text_match; null on the memory engine), raw (the engine's
own hit). matchedTokens() lists every matched word, field after field.
Omnisearch\DBAL\Facet (final, public readonly): field, values (a list of
['value' => string, 'count' => int], plus highlighted where the engine gives it, the most
frequent first) and extra (what else the engine says of it: Typesense's stats, sampled...).
Batch
Omnisearch\DBAL\Batch (final): writes gathered and sent together. index($connection, $metadata, $object) and remove($connection, $metadata, $id) add one, count() counts them,
rollBack() forgets them. commit(?callable $onError = null): int sends them in the order they
came - consecutive writes of the same kind on the same connection and index in one call, so a
removal after an indexing of the same document wins - and returns how many writes were sent. A
failed call goes to $onError($exception, $connection, $metadata), and the next group is still sent;
with no $onError the exception is thrown, and the groups after it are not sent and are
forgotten (commit() empties the batch first). A search engine has
no transaction: what was sent before a failure stays sent.
ORM
Manager
Omnisearch\ORM\Manager (final): the application's search, as an EntityManager is its database.
new Manager(Registry $registry, MetadataRegistry $metadata, ?ObjectStore $store = null)
| Method | |
|---|---|
registry(), metadata(), store() |
what it was built with |
classMetadata(string|object $indexOrClass): ClassMetadata |
by index name, class or object; MappingException when none |
connection(string|object $indexOrClass): Connection |
the connection an index lives on (a ClassMetadata is accepted too) |
finder(string|object $indexOrClass): Finder |
the index's finder, made once |
create() |
every index created where it is not |
drop() |
every index dropped |
index(object ...$objects) |
these objects indexed now, each in its class's index, one call per object |
Finder
Omnisearch\ORM\Finder (final): the searches of one index. $metadata (public readonly) is its
ClassMetadata.
| Method | |
|---|---|
raw(Query|Request $query): Response |
the engine's answer: documents only |
search(Query|Request $query): Result |
the answer with the objects loaded from the ObjectStore, in the order of the hits; without a store, or when the answer failed or found nothing, a Result with no objects |
facets(array $fields, ?Query $query = null, array $checked = [], bool $sortByValue = false): array |
the facets of these fields among the documents $query finds (every document when null), with no hit asked for (perPage(0)); the same shape as Result::facets() |
Query
Omnisearch\ORM\Query (final): a search written fluently; every method returns the query.
| Method | |
|---|---|
new Query(?string $term = null), term(?string) |
the words |
in(string ...$fields) |
adds fields to search in, by importance |
where($field, $value), whereNot($field, $value), whereIn($field, array $values) |
=, !=, in |
filter(Filter $filter) |
any Filter |
facet(string ...$fields) |
adds fields to count |
maxFacetValues(?int $max) |
|
sortBy(string $field, string $direction = 'asc') |
adds a sort; anything but desc (any case) is asc |
page(int $page) |
at least 1 |
perPage(?int $perPage) |
|
option(string $name, mixed $value) |
an engine's own parameter, by its name (->option('infix', 'always')) |
cacheable(bool $cacheable = true) |
|
request(): Request |
the Request it describes |
Result
Omnisearch\ORM\Result (final): a search's answer for the application. $response (public
readonly) is the engine's Response.
| Method | |
|---|---|
ok(), status(), error() |
the response's |
found(), page() |
|
totalPages(int $perPage): int |
ceil(found / perPage) |
hits(): array |
the response's hits |
objects(): array |
the objects loaded, in the order of the hits; a hit whose object is gone is left out |
hit(object $object): ?Hit |
the hit an object was loaded from (matched by the store's identify()) |
facets(array $checked = [], bool $sortByValue = false): array |
by field: ['field' => ..., 'counts' => [value => ['value', 'count', 'checked', ...]], 'extra' => [...]]; checked is true for the values given in $checked[field]; $sortByValue orders the values by strcmp() instead of the engine's order |
Result::facetsOf(Response $response, ...) (static) is the same on a bare Response.
ObjectStore
Omnisearch\ORM\ObjectStore: where the objects behind the documents live.
identify(object $object): string- the identifier of an object's document;load(ClassMetadata $metadata, array $ids, bool $cacheable = false): array- the objects of these identifiers that are found, in the order of$ids.
Two are shipped: Omnisearch\Bridge\Doctrine\DoctrineObjectStore (Doctrine's entities, below) and
Omnisearch\Memory\Identity (an object's getId(), else its public $id; loads nothing back -
the memory engine's default).
DoctrineObjectStore
new DoctrineObjectStore(ObjectManager|ManagerRegistry $doctrine). An entity is identified by its
identifier's values, joined by - when composite (an object value by its __toString()). load()
asks the entity's manager in one query: with the ORM a SELECT e FROM <class> e WHERE e.<id> IN (:ids) (for a composite identifier, each id split back into its values at its - - every split
a value holding - allows - as (e.<a> = ... AND e.<b> = ...) OR ..., the entities whose
identify() is the id kept), made cacheable in the second-level cache (and in the entity's cache region) when
$cacheable; with another manager findBy([<id> => $ids]) (Doctrine's MongoDB ODM, not
verified). The entities come back in the order of the identifiers, those not found left out.
Transformer
Omnisearch\ORM\Transformer (final): an object into its document, for the drivers that hold plain
documents (the memory engine). document(ClassMetadata $metadata, object $object): array is
['id' => $store->identify($object)] followed by each field read by its path and cast to its type.
A list keeps its non-null, non-empty values, each cast to the list's item type; a single value is
cast to the field's type, except null on an optional field, which stays null. An engine's package
may convert objects its own way (omnisearch/typesense uses glitchr/typesense-bundle's transformer).
PropertyReader
Omnisearch\ORM\PropertyReader::read(object $object, string $path, bool $list = false): mixed
reads a value by a dotted path, without a framework:
- each segment is read by a public getter (
get<Segment>,is<Segment>,has<Segment>), a public method of that very name (isAudible), a public property, anArrayAccesskey, or__get(), in that order; an array is read by key; what cannot be read is null, and the path stops at a null or false value; - a segment on a list (an array, a
Traversablesuch as a Doctrine collection) is read on every item:translations.titlegives every translation's title; - a list at the end is an array whose objects with
__toString()are strings (tagsgives each tag's string); nested arrays are flattened; with$list, a single value is wrapped in an array.
Indexer
Omnisearch\ORM\Indexer (final): the indexing rule, independent of any persistence's events.
new Indexer(Manager $manager, ?callable $onError = null, ?array $connections = null)
persisted(), updated(), removing() (before the object goes: its identifier taken from the
manager's store), removed(), flush(): int (the writes sent as one Batch), clear() (forgets
what is pending), supports(object): bool (a declared class, on one of $connections when given).
Indexing describes it in use.
The memory engine
Omnisearch\Memory\MemoryDriverFactory (memory, no option) makes a MemoryDriver over a
Transformer and the store given to the factory (Identity by default). The driver keeps documents
by index and identifier in the process:
- create, drop: an empty index made where it is not; dropped with its documents;
- schema:
['name' => index, 'fields' => [['name', 'type', 'facet', 'infix'], ...]]; - search on an index not created:
Response::failed(404, 'No index "x".'); - words: the term is lower-cased and split on spaces (
*is no word); each word must match at least one field - a token of the field's text (split on what is neither a letter nor a digit) that starts with the word, or holds it anywhere in aninfixfield. The fields areRequest::$fields, every field of the index when none. The matched tokens are the hit's highlights, as written in the document; - filters:
Filter::matches()on the document's value; - sort: by each sort key in turn (PHP's
<=>on the document's values), stable; - facets: the values of the hits found (a boolean as
true/false), the most frequent first,maxFacetValuesof them (10 by default); - pages:
perPage(10 by default) frompage;foundcounts every hit;scoreis null.
Nothing outlives the process, and two drivers share nothing: each create() of the factory makes
an empty one.
Proposer une modification docs/concepts.md
Mapping
A class says it is indexed with #[Index], and what of it with #[Field]:
use Omnisearch\Mapping\{Field, Index, Type};
#[Index('blogArticle', connection: 'default', options: ['typesense' => ['default_sorting_field' => 'publishedAt']])]
#[Field('state', property: 'state', facet: true)]
#[Field('title', type: Type::STRINGS, property: 'translations.title', infix: true)]
#[Field('tags', type: Type::STRINGS, property: 'tags.slug', facet: true)]
#[Field('audio', type: Type::BOOL, property: 'isAudible', facet: true)]
class Article
{
#[Field(type: Type::DATETIME, sort: true)] // on a property: its name, its path
private ?\DateTimeInterface $publishedAt;
#[Field('readingTime', type: Type::INT)] // on a method: its name without get/is/has
public function getReadingTime(): int { ... }
}
#[Field] |
|
|---|---|
name |
the field in the document (required on the class) |
type |
Type::STRING, STRINGS (a list), INT, INTS, BIGINT, FLOAT, FLOATS, BOOL, BOOLS, DATETIME (a timestamp), GEOPOINT |
property |
the path its value is read from: translations.title goes through a list (every item), tags gives each tag's __toString(), isAudible calls that method; the name when none |
facet |
its values counted, and filtered on |
sort |
results sortable by it |
infix |
matched by the middle of a word, where the engine can |
optional |
a document may lack it |
locale |
the language its text is tokenized in |
options |
the engine's own settings, under its name (['typesense' => ['stem' => true]]) |
Every argument, its type and default: Concepts.
Names and paths
- On the class,
nameis required andpropertydefaults to the name. - On a property, both default to the property's name (
publishedAt). The value is read from outside the object: a private property needs a public getter (getPublishedAt(),isPublished(),hasTags()), and without one it reads null. - On a method, the name is the method's without a leading
get,isorhasfollowed by a capital (getReadingTimegivesreadingTime,isAudiblegivesaudible), and the path is the method's own name, so the method itself is called. - The fields keep their declared order: the class's attributes first, then the properties', then
the methods' - the order
omnisearch:indexesandschema()show them in. - A name declared twice on a class, a field on the class without a name, a type not in
Type::ALLareMappingExceptions when the class is loaded.
On the memory engine (and any driver that uses ORM\Transformer), a path is read by
PropertyReader (Concepts): a getter, a method of that name, a
public property, an ArrayAccess key or __get(); through a list on each item; what cannot be read
is null. A list field gets a list whatever the path gives (a single value wrapped); a DATETIME
gets a timestamp. omnisearch/typesense hands the same path to glitchr/typesense-bundle's
transformer, which reads it its own way.
The identifier
The document's identifier is not a field: it is the object store's identify() - Doctrine's
identifier with DoctrineObjectStore, getId() or $id on the memory engine without a store. An
engine's package may add what its engine needs: omnisearch/typesense lets glitchr/typesense-bundle
add its id and primary fields from Doctrine's identifier, and a discriminator field and one
sub-collection per discriminator value for an entity with inheritance.
Sub-classes
An object of a sub-class is in its nearest declared parent's index: MetadataRegistry::find()
walks get_parent_class() until a class with an #[Index]. Attributes are not inherited: a
sub-class declaring its own #[Index] has an index of its own, with only the fields it declares.
Index options
#[Index]'s options are the engine's settings of the index; a driver reads its own, under its
engine's name, and ignores the rest. omnisearch/typesense reads default_sorting_field,
token_separators and symbols_to_index (from options['typesense'], or the options themselves
when there is no typesense key). The memory engine reads none.
Reading the metadata
The attributes are classes of this package and nothing else: an entity carrying them loads without omnisearch installed (PHP reads an attribute's class only when asked to), and without a framework.
use Omnisearch\Mapping\{AttributeLoader, ClassMetadata, MetadataRegistry};
$metadata = AttributeLoader::load(Article::class); // ?ClassMetadata
$all = AttributeLoader::scan([__DIR__.'/src/Entity']); // by index name
$registry = MetadataRegistry::fromDirectories([__DIR__.'/src/Entity']);
// Kept in a cache file, read back without reflection
file_put_contents('var/search.php', '<?php return '.var_export(array_map(fn (ClassMetadata $m) => $m->toArray(), array_values($all)), true).';');
$registry = MetadataRegistry::fromArray(require 'var/search.php');
scan() loads only the files whose text names Omnisearch\Mapping: the rest of an application's
code is never autoloaded. The Symfony bundle does this at compile time and keeps the arrays in the
container (Symfony).
Proposer une modification docs/mapping.md
Searching
$query = (new Query($term))
->in('title', 'excerpt', 'content') // the fields searched, by importance
->where('state', 'publish') // =; on a list field: holds
->whereNot('state', 'draft')
->whereIn('tags', ['a-table', 'voyages'])
->filter(new Filter('readingTime', Filter::LOWER, 10))
->facet('tags', 'audio')->maxFacetValues(250)
->sortBy('publishedAt', 'desc')
->page(2)->perPage(10)
->option('infix', 'always') // an engine's own parameter, by its name
->cacheable(); // the answer and the objects may be cached
$finder = $manager->finder('blogArticle'); // or Article::class, or an Article
$result = $finder->search($query); // the objects loaded
$response = $finder->raw($query); // the documents only
Result |
|
|---|---|
ok(), status(), error() |
an engine that cannot be reached or refuses answers a status (503, 400...) and its message, never an exception |
found(), page(), totalPages($perPage) |
|
objects() |
in the order of the hits; a hit whose object is gone is left out |
hit($object) |
its Hit: id, document, highlights (the matched words by field), matchedTokens(), score, raw |
facets($checked, $sortByValue) |
by field: counts by value (value, count, the engine's highlighted, checked), the engine's extra |
$finder->facets(['tags'], $query, $checked, $sortByValue) counts without the hits.
A worked example
On the memory engine, four notes of this class:
namespace App;
use Omnisearch\Mapping\{Field, Index, Type};
#[Index('note')]
#[Field('text', infix: true)]
#[Field('labels', type: Type::STRINGS, facet: true)]
final class Note
{
public function __construct(
public readonly string $id,
public readonly string $text,
#[Field(type: Type::INT, sort: true)] public readonly int $rank = 0,
public readonly array $labels = [],
) {
}
}
<?php // searching.php
require __DIR__.'/vendor/autoload.php';
use App\Note;
use Omnisearch\DBAL\Filter;
use Omnisearch\Mapping\MetadataRegistry;
use Omnisearch\Memory\MemoryDriverFactory;
use Omnisearch\ORM\{Manager, Query};
use Omnisearch\Registry;
$manager = new Manager(new Registry([new MemoryDriverFactory()], ['default' => ['driver' => 'memory']]), MetadataRegistry::fromDirectories([__DIR__.'/app/src']));
$manager->create();
$manager->index(
new Note('1', 'Mount Fuji at dawn', 3, ['travel', 'japan']),
new Note('2', 'Fujisan in music', 1, ['music', 'japan']),
new Note('3', 'A recipe for ramen', 2, ['food']),
new Note('4', 'Kyoto gardens', 5, ['travel', 'japan']),
);
$finder = $manager->finder('note');
$show = fn (string $label, Query $q) => printf("%-34s %s\n", $label, implode(' ', $finder->raw($q)->ids()) ?: '-');
$show('fuji in text', (new Query('fuji'))->in('text'));
$show('uji in text (infix)', (new Query('uji'))->in('text'));
$show('labels = japan', (new Query())->where('labels', 'japan'));
$show('labels != japan', (new Query())->whereNot('labels', 'japan'));
$show('labels in [food, music]', (new Query())->whereIn('labels', ['food', 'music']));
$show('rank >= 3, by rank desc', (new Query())->filter(new Filter('rank', Filter::GREATER_OR_EQUAL, 3))->sortBy('rank', 'desc'));
$show('page 2 of 2 per page, by rank', (new Query())->sortBy('rank')->page(2)->perPage(2));
$result = $finder->search((new Query())->facet('labels')->perPage(2));
printf("found %d, page %d, pages %d, hits %d\n", $result->found(), $result->page(), $result->totalPages(2), count($result->hits()));
echo json_encode($finder->facets(['labels'], (new Query('japan'))->in('labels'), ['labels' => ['travel']], sortByValue: true)), "\n";
$ php searching.php
fuji in text 1 2
uji in text (infix) 1 2
labels = japan 1 2 4
labels != japan 3
labels in [food, music] 2 3
rank >= 3, by rank desc 4 1
page 2 of 2 per page, by rank 1 4
found 4, page 1, pages 2, hits 2
{"labels":{"field":"labels","counts":{"japan":{"value":"japan","count":3,"checked":false},"music":{"value":"music","count":1,"checked":false},"travel":{"value":"travel","count":2,"checked":true}},"extra":[]}}
(run on 2026-10-26, PHP 8.4.27, the core's checkout, no network)
No store is given to the manager here, so search() would answer no objects: raw() and the hits'
identifiers are what this example reads.
Words and fields
in()names the fields the term is searched in, by importance; called several times, it adds. With none, the driver chooses: the memory engine searches every field, omnisearch/typesense everystringandstring[]field.- A word is found by its beginning (
fujifinds "Fujisan"); in aninfixfield, by its middle too where the engine does it - always on the memory engine, on Typesense only when the query asks (->option('infix', 'always')or'fallback'). - On the memory engine every word of the term must be found, each in one of the fields; Typesense
applies its own rules (typos,
drop_tokens_threshold...). A null or empty term finds every document (with the filters applied), on both. Result::hit($object)->highlightsgives the matched words by field;matchedTokens()all of them, field after field - to mark them in a page.
Filters
where(), whereNot(), whereIn() and filter(new Filter(...)) add conditions; every condition
must hold. On a list field (labels), = means "holds the value", != "does not hold it", in
"holds one of them", not in "holds none". The comparisons (<, <=, >, >=) are for numbers
and dates (a DATETIME field holds a timestamp: compare with $date->getTimestamp()).
Facets
facet('labels') counts the values of the documents found, at most maxFacetValues() per field
(the engine's default when not set: 10 on the memory engine). Result::facets() gives them by
field and by value, with checked set for the values given:
$facets = $result->facets(checked: ['tags' => $request->query->all('tags')], sortByValue: true);
foreach ($facets['tags']['counts'] as $value => $count) {
// $count['value'], $count['count'], $count['checked'], and $count['highlighted'] on Typesense
}
The engine's order is the most frequent first; sortByValue: true orders by value (strcmp()).
Finder::facets($fields, $query) asks the engine for the counts only (perPage(0)): the values of
a filter list, without loading a page of hits.
Sort and pages
sortBy($field, 'asc'|'desc') adds a sort key; several keys apply in order. page() starts at 1
(a lower number becomes 1); perPage() defaults to the engine's (10 on the memory engine).
found() counts every page; totalPages($perPage) is ceil(found / perPage); a page past the last
has no hits.
An engine's own parameters
option($name, $value) passes what only one engine knows, by that engine's name; another engine
ignores it. omnisearch/typesense sends infix through the bundle's setter and every other option as
a Typesense search parameter (num_typos, pinned_hits, prioritize_exact_match...). The memory
engine reads none.
Caching
cacheable() marks the search: the engine's answer may be kept (omnisearch/typesense: the bundle
finder's cache, a successful answer only, cleared by the driver after each write of the index), and
DoctrineObjectStore makes the query that loads the entities cacheable in Doctrine's second-level
cache. The memory engine ignores it.
When the engine does not answer
search() and raw() do not throw for an engine's refusal: the Result (or Response) is not
ok(), status() is the HTTP status, error() the engine's message, there are no hits and no
objects. A page shows "search unavailable" instead of failing:
$result = $finder->search($query);
if (!$result->ok()) {
$logger->warning('Search failed', ['status' => $result->status(), 'error' => $result->error()]);
}
Keeping the indexes in step
Keeping the indexes in step with the objects - Manager::index(), the Indexer and Doctrine's
listener - is on its own page: Indexing.
Proposer une modification docs/searching.md
Indexing
Three ways to put documents in an index, from the most manual:
$manager->index(...$objects): these objects indexed now, each in its class's index - an import, a command that rebuilds an index;Omnisearch\ORM\Indexer: the rule a persistence's events call, sent once at the end;Omnisearch\Bridge\Doctrine\IndexListener: the rule on Doctrine's events (registered by the Symfony bundle, or by hand).
$manager->create() makes every index where it is not, $manager->drop() removes them all. To
make one index again (after a field became infix, for instance), drop and create it on its
connection, then index its objects again:
$metadata = $manager->classMetadata(Article::class);
$connection = $manager->connection($metadata);
$connection->drop($metadata);
$connection->create($metadata);
$manager->index(...$entityManager->getRepository(Article::class)->findAll());
The rule
Indexer is independent of any persistence's events:
| Call | When | What it does |
|---|---|---|
persisted($object) |
after an object is saved the first time | queues it to be indexed, when its class declares an index |
updated($object) |
after a change is saved | the same: a write replaces the document |
removing($object) |
before the object is deleted | takes its identifier from the manager's store, while it still has one |
removed($object) |
after it is deleted | queues the removal of that identifier |
flush() |
once the persistence has committed | sends everything as one Batch; returns the writes sent |
clear() |
after a rollback | forgets what is queued |
An object whose class declares no index (nor a parent of it) is ignored. The Batch sends the
writes in their order, consecutive writes of one index in one call, so a removal queued after an
indexing of the same document wins.
Failures
new Indexer($manager, $onError): a write an engine refuses, or that cannot reach it, goes to
$onError(\Throwable $e) and the next writes are still sent; the application's own flush is not
stopped. Without $onError, the exception is thrown out of flush() (out of Doctrine's
flush() when the listener calls it, after the database committed), and the writes after it are
forgotten.
$indexer = new Indexer($manager, function (\Throwable $e) use ($logger): void {
$logger->error('Search index not updated', ['exception' => $e]);
});
The Symfony bundle gives its Indexer an $onError that logs, by its on_index_error option
(Symfony); throw keeps the exception.
removing() takes the identifier from the manager's store, or, on a Manager built without one,
as the memory engine takes it (Memory\Identity: getId(), else a public $id).
Some connections only
new Indexer($manager, $onError, ['default']): only the objects whose index lives on one of these
connections are indexed. The Symfony bundle uses it to leave alone the connections an engine's own
bundle indexes (glitchr/typesense-bundle's listener for a hosted typesense connection).
On Doctrine's events
IndexListener::EVENTS are postPersist, postUpdate, preRemove, postRemove and
postFlush; each calls the Indexer method of the table above, postFlush its flush(). A whole
script, on SQLite in memory and the memory engine, with DoctrineObjectStore loading the entities
back:
// orm/Entity/Article.php
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Omnisearch\Mapping\{Field, Index, Type};
#[ORM\Entity]
#[Index('article')]
class Article
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
public function __construct(
#[ORM\Column] #[Field] private string $title,
#[ORM\Column(type: 'json')] #[Field(type: Type::STRINGS, facet: true)] private array $tags = [],
) {
}
public function getId(): ?int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
public function getTags(): array { return $this->tags; }
}
<?php // indexing.php
require __DIR__.'/vendor/autoload.php';
require __DIR__.'/orm/Entity/Article.php';
use App\Entity\Article;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\{EntityManager, ORMSetup};
use Doctrine\ORM\Tools\SchemaTool;
use Omnisearch\Bridge\Doctrine\{DoctrineObjectStore, IndexListener};
use Omnisearch\Mapping\MetadataRegistry;
use Omnisearch\Memory\MemoryDriverFactory;
use Omnisearch\ORM\{Indexer, Manager, Query};
use Omnisearch\Registry;
$config = ORMSetup::createAttributeMetadataConfig([__DIR__.'/orm/Entity'], true);
$config->enableNativeLazyObjects(true);
$entityManager = new EntityManager(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config), $config);
(new SchemaTool($entityManager))->createSchema($entityManager->getMetadataFactory()->getAllMetadata());
$store = new DoctrineObjectStore($entityManager);
$manager = new Manager(new Registry([new MemoryDriverFactory($store)], ['default' => ['driver' => 'memory']]), MetadataRegistry::fromDirectories([__DIR__.'/orm/Entity']), $store);
$manager->create();
$errors = [];
$entityManager->getEventManager()->addEventListener(IndexListener::EVENTS, new IndexListener(new Indexer($manager, function (\Throwable $e) use (&$errors) { $errors[] = $e->getMessage(); })));
$finder = $manager->finder(Article::class);
$titles = fn (string $term) => implode(', ', array_map(fn (Article $a) => $a->getId().' '.$a->getTitle(), $finder->search((new Query($term))->in('title'))->objects())) ?: '-';
$fuji = new Article('Mount Fuji', ['travel']);
$entityManager->persist($fuji);
$entityManager->persist(new Article('Fujisan in music', ['music']));
$entityManager->flush(); // postPersist x2, then postFlush sends them
echo 'after persist: ', $titles('fuji'), "\n";
$fuji->setTitle('Kyoto gardens');
$entityManager->flush(); // postUpdate
echo 'after update: ', $titles('fuji'), ' | kyoto: ', $titles('kyoto'), "\n";
$entityManager->remove($fuji);
$entityManager->flush(); // preRemove takes id 1, postRemove removes it
echo 'after remove: ', $titles('kyoto'), "\n";
echo 'errors: ', count($errors), "\n";
$ php indexing.php
after persist: 1 Mount Fuji, 2 Fujisan in music
after update: 2 Fujisan in music | kyoto: 1 Kyoto gardens
after remove: -
errors: 0
(run on 2026-10-26, PHP 8.4.27, Doctrine ORM 3 with native lazy objects, no network)
The entity implements nothing of omnisearch; its private properties are read through their getters.
An identifier generated by the database is there at postPersist, so the document's id is the row's.
Flushes and transactions
Each Doctrine flush ends with postFlush, which sends what that flush queued. It runs at the end of
the flush, not at the commit of an outer transaction: inside $entityManager->wrapInTransaction(),
a flush has already sent its writes when the transaction is rolled back. A flush that fails before
postFlush leaves what it queued in the Indexer, sent by the next successful flush; call its
clear() after such a failure to forget them.
Proposer une modification docs/indexing.md
Configuration reference
The registry
new Omnisearch\Registry(iterable $factories, array $config, ?string $default = null);
| Argument | Type | ||
|---|---|---|---|
$factories |
iterable of DriverFactory |
required | keyed by their name(); a later factory of the same name replaces an earlier one |
$config |
array<string, array{driver: string, options?: array}> |
required | the connections by name |
$default |
?string | null |
the default connection; the first key of $config when null |
Each connection:
| Key | Type | Default | |
|---|---|---|---|
driver |
string | required | a factory's name(); unknown: InvalidConfigException on first use |
options |
array | [] |
handed to the factory's create() on first use; the connection is then kept |
Nothing is checked when the registry is built: a missing driver or a bad option is said when the connection is first asked for.
The Symfony bundle
The omnisearch tree - default_connection, connections.<name>.driver,
connections.<name>.options, mappings - is in Symfony.
connections is the registry's $config, default_connection its $default.
Driver options
memory
Omnisearch\Memory\MemoryDriverFactory reads no option. Its constructor takes the store that
identifies the objects: new MemoryDriverFactory(ObjectStore $store = new Identity()). Give it the
same store as the Manager, so that the documents' identifiers are the ones the store loads back.
typesense
Omnisearch\Typesense\TypesenseDriverFactory (omnisearch/typesense):
new TypesenseDriverFactory(?TypesenseManager $manager = null, ?ObjectManager $objectManager = null).
| Option | Type | Default | |
|---|---|---|---|
connection |
string | default |
the bundle's connection to use when the factory has glitchr/typesense-bundle's manager and it has that connection; also the name of the connection the driver makes on its own |
secret |
string | - | the API key; required when the driver makes its own connection (InvalidConfigException otherwise) |
url |
string | - | http://host:port; its parts fill scheme, host, port, path |
scheme |
string | http |
|
host |
string | localhost |
|
port |
int | 8108 |
|
path |
string | '' |
kept by the bundle's configuration |
options |
array | [] |
the Typesense PHP client's own options (connection_timeout_seconds, num_retries, retry_interval_seconds...) |
prefix |
string | '' |
put in front of every collection's name on the server, for the collections the driver makes (not those of the bundle's configuration) |
A connection of its own also needs Doctrine's object manager (the factory's second argument): the bundle's transformer reads the entities' metadata. The provider's page has the whole behaviour: omnisearch/typesense.
The attributes
#[Index] (name, connection, options) and #[Field] (name, type, property, facet,
sort, infix, optional, locale, options): every argument with its type and default is in
Concepts; how names and paths are derived in Mapping.
Engine options read today:
| Where | Key | Engine | |
|---|---|---|---|
#[Index(options: ...)] |
default_sorting_field |
typesense | the collection's default sort; the bundle's primary identifier field when not given |
#[Index(options: ...)] |
token_separators |
typesense | default ['+', '-', '@', '.', ' '] |
#[Index(options: ...)] |
symbols_to_index |
typesense | default ['+'] |
#[Field(options: ...)] |
any | typesense | sent as the bundle's field options, with sort, optional, locale added from the field's own arguments |
Under the key typesense (options: ['typesense' => [...]]), or flat when there is no such key.
Query options
Query::option($name, $value) (Request::$options):
| Option | Engine | |
|---|---|---|
infix |
typesense | off, always or fallback, one value or one per searched field (comma-separated); another value is dropped by the bundle |
| any other | typesense | sent as a Typesense search parameter of that name (num_typos, pinned_hits, drop_tokens_threshold...) |
| any | memory | none read |
Proposer une modification docs/configuration.md
Errors
A search does not throw for the engine's sake: it answers a Response (a Result) that is not
ok(), with the status and the engine's message. Writes, configuration and mapping throw.
The exceptions
\RuntimeException
└── Omnisearch\Exception\OmnisearchException every exception of omnisearch and its drivers
├── DriverException the engine refused a write, or was not reached
├── InvalidConfigException a connection or driver not configured, or not installed
└── MappingException an index or field that cannot work
OmnisearchException is not final: catch it for "anything omnisearch". The three below are
final.
DriverException
Thrown by a driver's writes - create(), drop(), index(), remove() - when the engine refuses
or cannot be reached. Its code is the engine's HTTP status when there was one (400 a malformed
request or document, 404 no such collection...), 503 when there was none; its
previous exception is the engine library's. omnisearch/typesense prefixes the message with
Typesense: .
Creating an index that exists, dropping one that does not, removing an unknown identifier are not errors.
InvalidConfigException
| Message | Thrown by |
|---|---|
No "x" connection; configured: a, b. |
Registry::connection() |
No "y" driver for the "x" connection; installed: memory, typesense. |
Registry::connection() |
No "x" connection. |
Registry::driverName() |
A Typesense connection of its own needs the option "secret" (and "url", or "host"). |
TypesenseDriverFactory::create() |
A Typesense connection of its own needs Doctrine's object manager (the factory's second argument). |
TypesenseDriverFactory::create() |
MappingException
| Message | Thrown by |
|---|---|
A field declared on the class X needs a name. |
AttributeLoader::load() |
The field "f" is declared twice on X. |
AttributeLoader::load() |
The field "f" of X has the type "t"; one of string, string[], ... |
AttributeLoader::load() |
The index "i" is declared twice (X and Y). |
AttributeLoader::scan() (and the bundle at compile time) |
No index for "X"; declared: a, b. |
MetadataRegistry::get(), so Manager::finder(), classMetadata(), connection(), index() |
What is not an OmnisearchException
Omnisearch\DBAL\Filter's constructor throws PHP's \InvalidArgumentException for a filter that
can never work as written (an unknown operator, in/not in without an array): a mistake in the
code, thrown at once, not a failure to catch - OmnisearchException's docblock says so. An index
omnisearch/typesense cannot make (the bundle does not know it, the driver has no transformer) is
an InvalidConfigException.
A driver catches its engine library's transport exceptions whatever HTTP client the library was
given: omnisearch/typesense catches the PSR-18 interfaces, so Symfony's Psr18NetworkException
becomes a failed search or a DriverException too
(searching).
Failed searches
status() |
Engine | Meaning |
|---|---|---|
| 404 | memory | No index "x". - the index was not created, or was dropped |
| 404 | typesense | no such collection (Typesense answered HTTP 404: no such collection. when Typesense gave no message) |
| 4xx | typesense | the request was refused, with Typesense's message: a field not in the schema, infix asked on a field created without it (Could not find ... in the infix index) |
| 0, 5xx | typesense | not reached, or not ready (Typesense answered HTTP 503: not reached, or not ready.) |
$result = $finder->search($query);
if (!$result->ok()) {
// $result->status(), $result->error(); no hits, no objects
}
What to catch
- Around a search: nothing of omnisearch; test
ok(). - Around
Manager::create(),drop(),index(), a connection's writes:DriverException. - Around the indexing on flush: give the
Indexeran$onError(Indexing). InvalidConfigExceptionandMappingExceptionare errors of the application's code or configuration: let them fail the request, the command or the container's compilation.
Proposer une modification docs/errors.md
Testing
Testing an application that searches
The memory engine is the test double: the same mapping, queries, filters, facets and pages, in the PHP process, with nothing to start and nothing left behind. Point the connection at it in the test environment.
# config/packages/omnisearch.yaml
omnisearch:
connections:
default: { driver: typesense, options: { connection: default } }
when@test:
omnisearch:
connections:
default: { driver: memory }
Under Symfony, with DoctrineBundle, the memory connection is not hosted by any engine's bundle, so
omnisearch's own IndexListener indexes the entities on flush and DoctrineObjectStore loads them
back: a functional test that creates an article and searches for it goes through the whole rule.
The indexes must exist first: call Manager::create() in the test's setUp() (a new kernel has a
new, empty memory engine).
use Omnisearch\ORM\Manager;
use Omnisearch\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class ArticleSearchTest extends KernelTestCase
{
public function testAPublishedArticleIsFound(): void
{
self::bootKernel();
$search = self::getContainer()->get(Manager::class);
$search->create();
$entityManager = self::getContainer()->get('doctrine.orm.entity_manager');
$entityManager->persist(new Article('Mount Fuji'));
$entityManager->flush(); // indexed by the listener
$result = $search->finder(Article::class)->search((new Query('fuji'))->in('title'));
self::assertTrue($result->ok());
self::assertSame(['Mount Fuji'], array_map(fn (Article $a) => $a->getTitle(), $result->objects()));
}
}
What the memory engine does not reproduce: an engine's ranking (the memory engine keeps the
documents' order unless sorted), typo tolerance, stemming, the engine's own options
(Query::option() is ignored) and its refusals of a schema. A test of those runs against the
engine itself, as omnisearch/typesense's tests do beside a Typesense container (harness).
Without a framework, build the Manager on MemoryDriverFactory with an ObjectStore of the
test's (Installation); to check what was indexed without loading
objects, read the documents with Finder::raw().
Testing a driver's translation
A driver's pure functions can be tested without a server. omnisearch/typesense's own tests do it:
TypesenseDriver::query($metadata, $request)->getHeaders() (the Typesense parameters a request
becomes), TypesenseDriver::filter($filter) (the filter_by syntax), TypesenseDriver::response()
on a recorded answer, Mapping::toTypesense($metadata) (the bundle's mapping).
The conformance suite
Omnisearch\Testing\DriverContractTests is the Driver contract as PHPUnit tests, a trait every
driver package uses in a Tests/<Name>ContractTest.php of its own (the memory engine in this core's
Tests/MemoryDriverContractTest.php, omnisearch/typesense in Tests/TypesenseContractTest.php).
It requires phpunit/phpunit (the core's require-dev; suggested).
The test case
use Omnisearch\DBAL\DriverFactory;
use Omnisearch\Memory\MemoryDriverFactory;
use Omnisearch\Testing\ContractDocument;
use Omnisearch\Testing\DriverContractTests;
use PHPUnit\Framework\TestCase;
final class MemoryDriverContractTest extends TestCase
{
use DriverContractTests;
protected function factory(): DriverFactory
{
return new MemoryDriverFactory();
}
protected function options(): array
{
return [];
}
protected function document(string $id, string $title, int $rank, array $labels): object
{
return new ContractDocument($id, $title, $rank, $labels);
}
}
| Method | Required | |
|---|---|---|
factory(): DriverFactory |
yes | the factory under test |
options(): array |
yes | the options of a connection to a working engine, the same for the whole test (give a random prefix on a shared server) |
document(string $id, string $title, int $rank, array $labels): object |
yes | an object of the indexed class with these values, identified by $id |
metadata(): ClassMetadata |
no | the index the documents go to; by default the one their class declares (\LogicException if none) |
requiredOptions(): array |
no | the options a connection cannot do without; [] by default |
unreachableOptions(): ?array |
no | the options of a connection to an engine that does not answer; null by default (an engine in the process) |
The documents' class declares an index with the fields title (Type::STRING), rank
(Type::INT, sort: true) and labels (Type::STRINGS, facet: true).
Omnisearch\Testing\ContractDocument (#[Index('omnisearch_contract')]) is one, for an engine that
indexes any object; a driver that indexes entities gives an entity of its own (omnisearch/typesense:
a Doctrine entity with an integer identifier).
Each test works on an index of its own making: an #[After] method drops it, so a shared server is
left as it was.
Each test
The three documents: 1 "Mount Fuji at dawn" (rank 3, travel and japan), 2 "Fujisan in music"
(rank 1, music and japan), 3 "A recipe for ramen" (rank 2, food).
| Test | Checks |
|---|---|
testTheFactoryNamesItsEngineAndTheRegistryConnectsIt |
the name matches ^[a-z][a-z0-9_-]*$; a Registry of {driver, options} gives that driver name and a Driver, the same connection twice and as the default |
testARequiredOptionLeftOutIsAConfigurationError |
each of requiredOptions() left out is an InvalidConfigException naming it; with none required, a driver is made from [] |
testTheContractsIndexIsWhatTheSuiteExpects |
the index has title (string), rank (int, sortable), labels (strings, facet) |
testAnIndexIsCreatedOnceDescribedAndDropped |
create() twice is no error, schema() is not empty, a new index is empty; drop() twice is no error; a search of the dropped index answers a status of at least 400, a message, no hits |
testDocumentsAreFoundByTheirWords |
fuji in title finds 1 and 2 (found 2), highlighted in title only, each matched token starting with "fuji", the document holding the title; ramen finds 3; kyoto nothing; no term and '' find all three |
testFiltersHold |
= and != on a list (holds, holds not), in, not in, >, >=, <, <=, = on a number, and a term with two filters at once |
testSortAndPages |
rank ascending 2, 3, 1 and descending 1, 3, 2; page 2 of one per page is 3, found 3, page 2; page 4 is empty |
testFacetsCountTheDocumentsFound |
labels counted (food 1, japan 2, music 1, travel 1), japan first; with the term fuji only the found documents' values (japan 2, music 1, travel 1) |
testAWriteReplacesAndARemovalForgets |
indexing id 1 again replaces it (its old words gone, three documents still); remove('1', 'not-indexed') forgets it, an unknown id and a second removal are no errors |
testAnEngineThatDoesNotAnswerAnswersAStatus |
with unreachableOptions(): a search answers a status of at least 400 and a message; an index() throws an OmnisearchException with a message. Skipped (counted as one assertion) when null |
Every successful search also checks that each hit is a Hit with an identifier and that found
is at least the number of hits.
An engine reached over the network runs the suite beside a server; omnisearch/typesense's test
case skips the tests that need one when no healthy server answers at TYPESENSE_URL, and runs the
four that need none (factory, options, mapping, an engine that does not answer).
Proposer une modification docs/testing.md
The Docker harness
docker/ runs this package with every omnisearch/* driver - from GitHub (branch 1.x), or from
the checkouts beside this one when OMNISEARCH_PLUGINS=../.. is set in docker/.env - and a
Typesense server (30.0, data in memory).
cd docker && cp .env.dist .env # the checkouts of the workspace
docker compose run --rm omnisearch test # every package's tests
docker compose run --rm omnisearch bare # plain PHP: memory, then Typesense, and what PHP loaded
docker compose run --rm omnisearch bare --memory
docker compose down -v
bare builds everything by hand: on the memory engine, notes indexed by the rule, searched,
faceted; then Doctrine entities on SQLite indexed on flush in Typesense through
omnisearch/typesense, searched (sorted, matched tokens, facets, infix, a filter), loaded back, the
collection dropped. Tests/BareTest runs it in a PHP process of its own and checks that nothing of
Symfony nor Doctrine is loaded on the memory engine, and no class of a framework through Typesense.
The services
| Service | Image | |
|---|---|---|
omnisearch |
omnisense/omnisearch-harness, built from docker/Dockerfile (php:8.4-cli-alpine, the zip extension, Composer 2) |
this checkout at /omnisearch/core, the drivers' checkouts at /omnisearch/plugins (read-only), the volume harness at /harness (the generated project, its vendor/ and Composer's cache) |
typesense |
typesense/typesense:30.0.rca41 |
--data-dir /data on a tmpfs, the API key TYPESENSE_KEY (xyz by default); healthy once /health answers "ok":true, which omnisearch waits for |
The omnisearch service is given TYPESENSE_URL=http://typesense:8108 and TYPESENSE_KEY.
Environment
| Variable | Default | |
|---|---|---|
OMNISEARCH_PLUGINS |
./plugins (empty) |
the directory holding the drivers' checkouts, one per slug (typesense/); .env.dist sets ../.., the workspace |
TYPESENSE_KEY |
xyz |
the Typesense server's API key |
What the entrypoint does
docker/entrypoint.sh runs docker/harness/setup.php, which writes /harness/composer.json and
/harness/phpunit.xml:
- this core from its checkout (a path repository,
1.x-dev); - each driver of
docker/harness/plugins.phpfrom its checkout under/omnisearch/plugins/<slug>when itscomposer.jsonnamesomnisearch/<slug>, otherwise cloned fromhttps://github.com/glitchr-omnisense/omnisearch-<slug>.git(branch 1.x); doctrine/orm,ext-pdo_sqlite, and for the testsdoctrine/doctrine-bundle,symfony/framework-bundle,symfony/cache,config,console,dependency-injection,http-client,http-kernel, PHPUnit 11 - symfony/http-client so that php-http's discovery gives typesense-php Symfony's PSR-18 client, as in an application, and the suite's unreachable-engine test meets itsPsr18NetworkException;- one PHPUnit suite per package (
core,typesense).
The files are rewritten only when they change. Composer installs when there is no vendor/, and
updates when composer.json is newer than composer.lock. Then the command:
| Command | |
|---|---|
test [phpunit arguments] (the default) |
vendor/bin/phpunit on every suite, e.g. test --testsuite typesense |
bare [--memory] |
docker/harness/bin/bare |
composer ..., sh, php ... |
as they are, in /harness |
Adding a driver
A line in docker/harness/plugins.php: the slug, the tests' namespace and the factory class:
return [
'typesense' => ['Omnisearch\\Typesense\\Tests\\', 'Omnisearch\\Typesense\\TypesenseDriverFactory'],
];
docker compose down -v removes the Typesense container and the harness volume (the next run
installs again).
Proposer une modification docs/harness.md
Drivers
A driver is what an engine does for omnisearch, in its own terms:
interface Driver
{
public function create(ClassMetadata $metadata): void; // the index, where it is not
public function drop(ClassMetadata $metadata): void;
public function schema(ClassMetadata $metadata): array; // what create() describes to the engine
public function index(ClassMetadata $metadata, object ...$objects): void;
public function remove(ClassMetadata $metadata, string ...$ids): void;
public function search(ClassMetadata $metadata, Request $request): Response;
}
and a DriverFactory (name(), create(array $options)) makes it from a connection's options;
Registry keeps one connection per name.
- A driver turns objects into documents itself - with
ORM\Transformer(the fields read byPropertyReader, typed, the store's identifier asid) or its engine library's own way (omnisearch/typesense uses glitchr/typesense-bundle's transformer, so its documents are the bundle's). - A
Request'sFilters, facets, sort and page are the same for all;optionshold what only one engine knows, by that engine's names; a driver reads its own and ignores the rest. search()answers aResponsewith a status and the engine's message when the engine refuses or cannot be reached - never an exception, and never an empty message.- A write (
create(),drop(),index(),remove()) the engine refuses, or that cannot reach it, throws anOmnisearch\Exception\DriverException, its code the engine's HTTP status (503 when there was none), the engine library's exception as its previous one. Creating an index that is there, dropping one that is not, removing an unknown identifier are no errors.
The memory engine
Omnisearch\Memory\MemoryDriver (memory) keeps the documents in the process: a word of the term
matches a word of a field that starts with it (or holds it, in an infix field), the filters,
facets, sort and pages of a Request work as documented. For tests, and an application's first
steps; nothing outlives the process. Its whole behaviour: Concepts.
Writing one
A package omnisearch/<engine>, namespace Omnisearch\<Engine>\: a DriverFactory, a Driver,
its tests on recorded answers of the engine and, when a server can run in Docker, the harness's
BareTest on it. Add it to docker/harness/plugins.php and to OmnisearchBundle::FACTORIES;
when the engine has its own Symfony bundle that should host the indexes, a Bridge\Symfony\Host
and an entry of OmnisearchBundle::HOSTS.
The shape of a driver for an engine with an HTTP API, holding plain documents:
namespace Omnisearch\Acme;
use Omnisearch\DBAL\{Driver, DriverFactory, Request, Response, Hit};
use Omnisearch\Exception\{DriverException, InvalidConfigException};
use Omnisearch\Mapping\ClassMetadata;
use Omnisearch\ORM\{ObjectStore, Transformer};
final class AcmeDriverFactory implements DriverFactory
{
public function __construct(private readonly ObjectStore $store) {}
public function name(): string
{
return 'acme';
}
public function create(array $options): Driver
{
if (empty($options['url'])) {
throw new InvalidConfigException('An Acme connection needs the option "url".');
}
return new AcmeDriver(new AcmeClient($options['url']), new Transformer($this->store));
}
}
final class AcmeDriver implements Driver
{
public function __construct(private readonly AcmeClient $client, private readonly Transformer $transformer) {}
public function index(ClassMetadata $metadata, object ...$objects): void
{
$documents = array_map(fn (object $o) => $this->transformer->document($metadata, $o), $objects);
try {
$this->client->upsert($metadata->index, $documents);
} catch (AcmeError $e) {
throw new DriverException('Acme: '.$e->getMessage(), $e->status ?: 503, $e);
}
}
public function search(ClassMetadata $metadata, Request $request): Response
{
try {
$answer = $this->client->search($metadata->index, /* the request in Acme's terms */);
} catch (AcmeError $e) {
return Response::failed($e->status ?: 503, $e->getMessage() ?: 'Acme not reached.');
}
return new Response($answer['total'], $request->page, array_map(
fn (array $hit) => new Hit((string) $hit['id'], $hit['document'], $hit['highlights'] ?? [], $hit['score'] ?? null, $hit),
$answer['hits'],
));
}
// create(), drop(), schema(), remove(): the same rules
}
(AcmeClient and AcmeError stand for the engine's own library.) Every exception a driver lets
out should be an OmnisearchException; catch the engine library's transport errors too, not only
its API errors - the client library may throw the HTTP client's own exceptions.
An application can also write a driver of its own without a package: under Symfony, any service
implementing DriverFactory is tagged omnisearch.driver_factory and its name() can be used as a
connection's driver.
Conformance suite
Omnisearch\Testing\DriverContractTests is the Driver contract as PHPUnit tests: every driver
package runs it in a Tests/<Name>ContractTest.php of its own (the memory engine in this core's
Tests/MemoryDriverContractTest.php). The test case gives the factory, the options of a working
connection, and the objects to index - of a class declaring an index with the fields title
(Type::STRING), rank (Type::INT, sort) and labels (Type::STRINGS, facet).
Omnisearch\Testing\ContractDocument is one, for an engine that indexes any object; a driver that
indexes entities gives an entity of its own.
It indexes three documents in an index of its own, dropped after each test (give a random
prefix on a shared server), and checks:
| Factory and registry | the factory names its engine; the Registry connects it from {driver, options}, once |
| Options | each of requiredOptions() left out is an InvalidConfigException naming it |
| Index | created where it is not (twice: no error), described by schema(), dropped with its documents (twice: no error); a search of an index not there answers a failed Response - a status, a message |
| Words | a word's beginning finds the documents, in the fields asked, with the words they matched; an empty term finds them all |
| Filters | =, !=, in, not in on a list field (holds / holds not), <, <=, >, >=, = on a number, with a term |
| Sort and pages | ascending, descending; perPage and page, found counting every page, nothing past the last |
| Facets | the values of the documents found, counted, the most frequent first |
| Writes | a write replaces the document of its identifier; remove() forgets it, an unknown identifier is no error |
| An engine that does not answer | unreachableOptions() (null for an engine in the process): a search answers a failed Response with a message, a write throws an OmnisearchException |
metadata() (by default the index the documents' class declares) and requiredOptions(),
unreachableOptions() are hooks with defaults. An engine reached over the network runs it beside
a server (omnisearch/typesense: the harness's Typesense container) - the tests that need none
(factory, options, mapping, an engine that does not answer) run anyway. Each test, its documents
and its assertions: Testing.
The suite proves the contract with the dependencies the package's tests install; an engine
library that picks its HTTP client at run time (php-http discovery) may behave otherwise in an
application with another client installed: catch the PSR-18 interfaces
(Psr\Http\Client\ClientExceptionInterface), not one client's classes, and install in the tests
the client an application is likely to have - the harness installs symfony/http-client for
omnisearch/typesense (searching).
Proposer une modification docs/drivers.md
Verified, and not
What the tests prove
docker compose run --rm omnisearch test in docker/, beside Typesense 30.0
(typesense/typesense:30.0.rca41); the README and the commits state the runs: 48 tests in the
workspace, none skipped, on 2026-10-25.
- The mapping (
MappingTest): attributes read in their order, metadata as plain values, a directory scanned, a sub-class in its parent's index, an unknown type and a nameless class field refused. PropertyReaderTest: paths, getters, lists, strings.- The layer on the memory engine (
SearchTest): objects in the order of the hits, filters, facets and pages, the indexing rule, anIndexerlimited to other connections, aBatchgrouping consecutive writes and handing a failure to$onError, a missing connection or driver named, an index not created answering a status. - Doctrine (
Bridge/DoctrineTest): entities indexed on flush and loaded back in the hits' order, on SQLite. - The bundle (
Bridge/OmnisearchBundleTest): the indexes of the attributes read at compile time, a finder injected by its index's name, no host for the memory engine; hosted by glitchr/typesense-bundle in omnisearch/typesense'sHostedTest. - In plain PHP (
BareTest,bin/bare): nothing of Symfony nor Doctrine loaded on the memory engine, no class of a framework through omnisearch/typesense. - The conformance suite (Testing) on the memory engine and on omnisearch/typesense beside the Typesense container.
Run for this manual
On 2026-10-26, PHP 8.4.27, no network, the core's and omnisearch/typesense's checkouts on the
autoloader (the Symfony, Doctrine and typesense-bundle classes from an application's installed
vendor/): the plain-PHP script of Installation, the examples of
Searching and Indexing, the
bundle's configuration tree, services and two commands (Symfony), the metadata
cache of Mapping, and omnisearch/typesense's mapping, request
translation and behaviour without a server (its manual). Their output is pasted as it came.
The harness itself was not run for this manual: its image is not built on the machine that wrote it.
On an application
glitchr's lesringostars declares four of its five collections with these attributes, through omnisearch/typesense and hosted by glitchr/typesense-bundle: the schemas are those its server held, byte for byte, its 628 documents and its blog's searches (pages, terms, the tags facet) unchanged; the entities load in its Laravel twin with nothing of Symfony (README).
What is not verified
- An engine other than Typesense and memory.
- Doctrine's MongoDB ODM:
DoctrineObjectStorefalls back onfindBy(), never run. - Laravel: no bridge; the service provider of Laravel is the Laravel twin's shape, not a tested package.