Skip to content

omnisense/omnisearch

Omnisearch: search the way Doctrine is for SQL, whatever the engine - indexes declared on the classes by attributes (#[Index], #[Field]), connections and drivers, requests and responses (DBAL), a manager, finders, queries, results and objects loaded back, the indexing rule (ORM); an in-memory engine, a Doctrine bridge and a Symfony bundle. Engines are packages of their own (omnisearch/typesense).

Floor
Family
Version
1.x
Conformance suite
runs, CI red
Verified for real
Verified in part
Licence
MIT
composer require omnisense/omnisearch:1.x-dev

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 driver changes.
  • 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

Propose a change 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/omnisearch requires nothing else.
  • Suggested, loaded only by the bridge that needs them: doctrine/orm (^3.0) for Omnisearch\Bridge\Doctrine; symfony/config, symfony/dependency-injection, symfony/http-kernel (^7.0 or ^8.0) for the bundle, symfony/console for its commands; phpunit/phpunit (^11.0) for the conformance suite.
  • omnisearch/typesense requires glitchr/typesense-bundle (^2.0), whose framework-free part it uses; the bundle requires doctrine/orm, typesense/typesense-php (^4.0 or ^5.0), php-http/curl-client and symfony/cache. It indexes Doctrine entities: it needs an ObjectManager (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 by AttributeLoader::load() or MetadataRegistry::fromDirectories([...]);
  • the manager (Omnisearch\ORM\Manager): create() and drop() the indexes, index() objects now, finder() per index;
  • the indexer (Omnisearch\ORM\Indexer): the rule a persistence's events call - persisted(), updated(), removing() then removed(), sent on flush();
  • the store (Omnisearch\ORM\ObjectStore): identify() and load(). Without one, the memory engine identifies an object by its getId() 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\OmnisearchBundle reads 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 Manager once, where the framework builds its services, as the scripts above do.

Propose a change 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: its typesense:create, typesense:action, typesense:update and 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.

Propose a change 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 the Manager in each process. To skip it, keep ClassMetadata::toArray() of each index in a cache file and build with MetadataRegistry::fromArray().
  • Indexing on writes. Nothing listens to Doctrine's events: register Omnisearch\Bridge\Doctrine\IndexListener on the entity manager's event manager (IndexListener::EVENTS), with an Indexer and an $onError (Indexing). An Eloquent model is not an entity: call Manager::index(), or an Indexer from the model's events, with an ObjectStore of the application's.
  • Commands. omnisearch:indexes and omnisearch:schema are Symfony Console commands. An Artisan command of the application prints the same from Manager::metadata()->all() and Manager::connection($index)->schema($metadata), and creates or drops the indexes with Manager::create() and Manager::drop().

Propose a change 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, else name). toArray() / fromArray().
  • ClassMetadata: class (the declaring class), index (its name), connection (?string), fields (FieldMetadata by 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 .php files under these directories, by index name, sorted. A file is read as tokens and loaded only when its text names Omnisearch\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, an ArrayAccess key, 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 Traversable such as a Doctrine collection) is read on every item: translations.title gives every translation's title;
  • a list at the end is an array whose objects with __toString() are strings (tags gives 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 an infix field. The fields are Request::$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, maxFacetValues of them (10 by default);
  • pages: perPage (10 by default) from page; found counts every hit; score is null.

Nothing outlives the process, and two drivers share nothing: each create() of the factory makes an empty one.

Propose a change 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, name is required and property defaults 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, is or has followed by a capital (getReadingTime gives readingTime, isAudible gives audible), 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:indexes and schema() show them in.
  • A name declared twice on a class, a field on the class without a name, a type not in Type::ALL are MappingExceptions 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).

Propose a change 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 every string and string[] field.
  • A word is found by its beginning (fuji finds "Fujisan"); in an infix field, 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)->highlights gives 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.

Propose a change 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.

Propose a change 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

Propose a change 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 Indexer an $onError (Indexing).
  • InvalidConfigException and MappingException are errors of the application's code or configuration: let them fail the request, the command or the container's compilation.

Propose a change 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).

Propose a change 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.php from its checkout under /omnisearch/plugins/<slug> when its composer.json names omnisearch/<slug>, otherwise cloned from https://github.com/glitchr-omnisense/omnisearch-<slug>.git (branch 1.x);
  • doctrine/orm, ext-pdo_sqlite, and for the tests doctrine/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 its Psr18NetworkException;
  • 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).

Propose a change 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 by PropertyReader, typed, the store's identifier as id) or its engine library's own way (omnisearch/typesense uses glitchr/typesense-bundle's transformer, so its documents are the bundle's).
  • A Request's Filters, facets, sort and page are the same for all; options hold what only one engine knows, by that engine's names; a driver reads its own and ignores the rest.
  • search() answers a Response with 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 an Omnisearch\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).

Propose a change 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, an Indexer limited to other connections, a Batch grouping 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's HostedTest.
  • 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: DoctrineObjectStore falls back on findBy(), never run.
  • Laravel: no bridge; the service provider of Laravel is the Laravel twin's shape, not a tested package.

Propose a change docs/verified.md