honey-odm / loupe
Loupe (SQLite) adapter for Honey ODM
Requires
- php: >=8.4
- honey-odm/core: ^1.0@beta
- loupe/loupe: ^1.0
- symfony/options-resolver: ^7.2|~8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- pestphp/pest: ^4.1
- phpstan/phpstan: ^2.1
- squizlabs/php_codesniffer: ^4.0
- symfony/var-dumper: ^7.2|~8.0
This package is auto-updated.
Last update: 2026-08-29 06:32:57 UTC
README
A Honey ODM adapter for Loupe, a Meilisearch-inspired search engine that stores its index in SQLite. There is no server to run, no network round-trip, no separate process to manage โ a Loupe index is just a couple of files on disk (or nothing at all, in memory).
The package is a thin translation layer: it turns the ODM's platform-agnostic Criteria into
Loupe's SearchParameters / BrowseParameters and filter grammar, and turns Loupe documents back
into your objects. It has no persistence logic of its own โ Loupe does all the work. It mirrors
honey-odm/meilisearch in structure and public
surface, so switching backends is mostly a matter of swapping the ObjectManagerFactory.
Features
- ๐ Modern PHP: Requires PHP 8.4+ with full type safety
- ๐๏ธ No server required: Loupe stores its index in SQLite, on disk or entirely in memory
- ๐ท๏ธ Attribute-based configuration: Use PHP 8 attributes to configure your entities
- ๐ Flexible querying: The portable
CriteriaAPI, or native LoupeSearchParameters/BrowseParametersfor what it doesn't model - ๐ Property transformers: Built-in transformers for dates, enums, relations, and custom data types
- ๐ฆ Repository pattern: Clean data access layer with repository interfaces
- ๐งช 100% test coverage: Thoroughly tested with Pest PHP ๐ฏ
- ๐งฒ Event system: Pre/Post Persist/Update/Remove events
Installation
composer require honey-odm/core:^1.0@beta honey-odm/loupe:^1.0@beta
honey-odm/core has no stable release yet. Composer only honours stability flags declared in the
root package, so the @beta carried by this package's own requirement isn't enough โ require the
core explicitly, as above, or set "minimum-stability": "beta" in your own composer.json.
Quick Start
1. Define your entities
Core attributes (AsDocument, AsField) describe the mapping and are shared across every Honey
ODM implementation. Loupe-specific concerns โ filterable / sortable / searchable / displayed
attributes, and index-wide settings โ live in AsAttribute and AsIndex from this package:
<?php use DateTimeInterface; use Honey\ODM\Core\Config\AsDocument; use Honey\ODM\Core\Config\AsField; use Honey\ODM\Core\Mapper\PropertyTransformer\DateTimeImmutableTransformer; use Honey\ODM\Loupe\Config\AsAttribute; use Honey\ODM\Loupe\Config\AsIndex; #[AsDocument(collection: 'movies')] // <-- One SQLite database per collection #[AsIndex(languages: ['en'], stopWords: ['the'], typoTolerance: false)] final class Movie { /** * @param list<string> $genres * @param array{lat: float, lng: float}|null $location */ public function __construct( #[AsField(name: 'movie_id', primary: true)] // <-- Exactly 1 property must be marked as primary key public int $id, #[AsField(name: 'title')] // <-- Optionally set the field name on Loupe's side #[AsAttribute(sortable: true, searchable: true)] public string $name, #[AsField] #[AsAttribute(filterable: true)] // <-- Array attribute: `genres = 'Action'` means "contains" public array $genres = [], #[AsField] #[AsAttribute(filterable: true, sortable: true)] public ?int $year = null, #[AsField] #[AsAttribute(filterable: true)] // <-- Any attribute can carry geo filters, no reserved `_geo` public ?array $location = null, #[AsField(name: 'released_at', transformer: DateTimeImmutableTransformer::class)] #[AsAttribute(sortable: true)] public ?DateTimeInterface $releasedAt = null, ) { } }
The same annotated class works on any other Honey ODM implementation โ the #[AsAttribute] /
#[AsIndex] flags are simply ignored there.
2. Configure the Object Manager
<?php use Honey\ODM\Loupe\ObjectManagerFactory; // On disk: one SQLite database per collection, under $dataDir/{collection}/ $objectManager = ObjectManagerFactory::create(dataDir: __DIR__ . '/var/loupe'); // In memory: nothing touches the filesystem - great for tests and ephemeral use $objectManager = ObjectManagerFactory::create(); // Get a repository for your entity $movieRepository = $objectManager->getRepository(Movie::class); // <-- Reads the AsDocument / AsField attributes automatically
Before you can persist anything, apply the schema โ this creates each collection's data directory and database:
use Honey\ODM\Core\Config\ClassMetadataRegistry; use Honey\ODM\Loupe\Schema\SchemaUpdater; $updater = new SchemaUpdater( $objectManager->transport->indexRegistry, // <-- LoupeTransport::$indexRegistry is public for exactly this new ClassMetadataRegistry(configurations: [Movie::class]), ); $updater->updateSchema();
3. Basic operations
<?php use Honey\ODM\Core\Criteria\Criteria; use function Honey\ODM\Core\Criteria\field; // Find all movies $movies = $movieRepository->findAll(); // Find by ID $movie = $movieRepository->find(1); // Find by criteria (array of equality filters, AND-combined) $movies = $movieRepository->findBy(['year' => 1977]); // Find one by criteria $movie = $movieRepository->findOneBy(['year' => 1977]); // Using the portable Criteria API for complex queries $movies = $movieRepository->findBy( Criteria::create()->where(field('genres')->equals('Action'))->orderBy('year', 'desc'), );
4. Persist your data
The Object Manager (not the repository) owns persistence:
$movie = new Movie(id: 1, name: 'Star Wars', genres: ['Action', 'SciFi'], year: 1977); $objectManager->persist($movie); $objectManager->flush(); $movie->year = 1978; $objectManager->flush(); // <-- Change on $movie detected, Loupe updated $objectManager->remove($movie); $objectManager->flush(); // <-- $movie removed from Loupe
The Criteria API
Criteria is the portable, platform-agnostic query builder shared by every Honey ODM
implementation. This package compiles it into Loupe's filter grammar and SearchParameters /
BrowseParameters objects, throwing rather than silently degrading whenever an expression can't
be translated:
use Honey\ODM\Core\Criteria\Criteria; use function Honey\ODM\Core\Criteria\field; use function Honey\ODM\Core\Criteria\not; $results = $movieRepository->findBy( Criteria::create() ->where( field('genres')->equals('Action'), not(field('year')->lessThan(1980)), ) ->orderBy('year', 'desc') ->limit(10), );
Operator support
| Core operator | Compiled form |
|---|---|
EQUALS, NOT_EQUALS |
field = v, field != v |
GREATER_THAN, GREATER_THAN_OR_EQUALS |
field > v, field >= v |
LESS_THAN, LESS_THAN_OR_EQUALS |
field < v, field <= v |
IN, NOT_IN |
field IN (โฆ), field NOT IN (โฆ) |
HAS_ALL |
(field = a AND field = b โฆ) |
IS_NULL, IS_NOT_NULL |
field IS NULL, field IS NOT NULL |
IS_EMPTY |
field IS EMPTY |
BETWEEN |
see below |
WITHIN_GEO_RADIUS |
_geoRadius(field, lat, lng, meters) |
WITHIN_GEO_BOUNDING_BOX |
_geoBoundingBox(field, north, east, south, west) |
CONTAINS, STARTS_WITH, ENDS_WITH, EXISTS |
UnsupportedExpressionException |
BETWEEN compiles to the native field BETWEEN l AND r only when both bounds are present, both
are inclusive, and both are numeric. Every other shape โ an open-ended bound, an exclusive bound,
or string bounds โ compiles to AND-combined comparisons, which Loupe handles fine (Loupe's own
BETWEEN parser accepts floats only; title BETWEEN 'A' AND 'M' is rejected, but
title >= 'A' AND title <= 'M' works).
Operators that throw, and why:
CONTAINS,STARTS_WITH,ENDS_WITHโ Loupe has no substring/LIKEoperator of any kind.EXISTSโ Loupe's schema is index-wide: every declared attribute is present on every document, and an undeclared one fails to parse. The operator has no meaningful answer here, and silently returning "always true" would be worse than a clear error.- A negated
WITHIN_GEO_RADIUS/WITHIN_GEO_BOUNDING_BOX(i.e.outsideGeoRadius(),outsideGeoBoundingBox()) โ Loupe offers no way to express "outside this radius / box".
Nested paths are not supported
A Loupe index is flat by construction. Attribute names are validated against
[a-zA-Z\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*, which has no room for a dot, so createdBy.id is
rejected outright:
InvalidConfigurationException: A valid attribute name starts with a letter, followed by any
number of letters, numbers, or underscores. "createdBy.id" given.
Meilisearch happily indexes and filters createdBy.id, so a document that needs to be filtered
on a nested object cannot be ported to Loupe as-is โ the "swap the ObjectManagerFactory and
you're done" promise stops there. The core agrees, for what it is worth:
AsDocument::getFieldName('createdBy.id') throws, so the portable Criteria cannot express such
a filter for any backend.
The way round it is to flatten what you filter on โ map a dedicated created_by_id field on the
document and filter on that instead of reaching into the nested object.
Negation by De Morgan pushdown
Loupe's filter grammar accepts NOT only immediately before IN and BETWEEN โ NOT (year > 2000)
is a syntax error, there is no way to negate a group. So instead of wrapping a compiled group in
NOT, negation is pushed down to the leaves:
not(A AND B)โnot(A) OR not(B);not(A OR B)โnot(A) AND not(B)not(not(A))โA- Comparisons flip to their opposite operator:
=โ!=,>โ<=,>=โ<,INโNOT IN,IS NULLโIS NOT NULL,IS EMPTYโIS NOT EMPTY,BETWEENโNOT BETWEEN.
This keeps the core's negative shorthands (notIn(), notBetween(), isNotEmpty(),
notStartsWith(), โฆ) working, since they are all not(...) wrappers underneath.
Documents holding no value are kept. Loupe compiles >, >=, < and <= to
attribute <op> value AND attribute IS NOT NULL, so they drop documents where the field is
empty โ while its native !=, NOT IN and NOT BETWEEN keep them. Left alone, that would make
the result depend on which compilation path a range happened to take: not(between(1970, 1980))
would return valueless documents through the native NOT BETWEEN, but
not(between(1970, 1980, includeLeft: false)) would not, so flipping a bound's inclusivity would
silently change the result set. Negated ordering comparisons restore those documents explicitly,
which is also what the core specifies โ notBetween() is documented as matching "values outside
the given interval, including fields holding no value at all".
Browse versus search
Criteria is compiled to one of two Loupe query objects, chosen by what it asks for:
| Criteria | Query object |
|---|---|
| no search term and no sort | BrowseParameters |
| a search term or a sort is given | SearchParameters |
BrowseParameters has no withSort() at all, so any criteria carrying an orderBy() has to go
through search(). Conversely, search() results are capped by Configuration::maxTotalHits
(1000 by default), while browse() is explicitly designed to walk an entire collection without
that ceiling. Browsing is therefore preferred whenever it can do the job โ it's both unbounded and
the cheaper of the two endpoints.
Escape hatch: native Loupe queries
Facets, highlighting, cropping, distinct and ranking scores are not modelled in the ODM's
Criteria โ extending it would be a separate, cross-implementation piece of work. They stay one
call away by passing a native Loupe query straight to the repository:
use Loupe\Loupe\SearchParameters; $movies = $movieRepository->findBy( SearchParameters::create() ->withQuery('star wars') ->withFacets(['genres']) ->withAttributesToHighlight(['title']), );
ObjectRepositoryInterface::findBy() / findOneBy() accept Criteria, a plain array of equality
filters, a native SearchParameters / BrowseParameters, or this package's own LoupeCriteria
wrapper (which additionally carries the pagination batch size, see LoupeCriteria::setDefaultBatchSize()).
Schema management
SchemaUpdater walks every registered collection, instantiating each index โ which creates its
data directory and database on first use โ and reports whether the stored index still matches its
configuration:
use Honey\ODM\Core\Config\ClassMetadataRegistry; use Honey\ODM\Loupe\Schema\SchemaUpdater; $updater = new SchemaUpdater( $objectManager->transport->indexRegistry, new ClassMetadataRegistry(configurations: [Movie::class, Actor::class]), ); $updater->updateSchema(function (string $class, $metadata, bool $needsReindex) { if ($needsReindex) { // Loupe froze this index's configuration at creation time - your #[AsAttribute] / // #[AsIndex] attributes have since changed, and the stored data no longer matches them. // Deciding whether (and how) to re-index is an application concern, not this library's: // updateSchema() only ever reports drift, it never re-indexes on its own. } });
dropSchema() deletes every collection's data directory (or discards the in-memory instance):
$updater->dropSchema(function (string $class, $metadata) { // ... });
Data Transformers
Transform data between PHP objects and Loupe documents:
Built-in transformers
DateTimeImmutableTransformer: ConvertDateTimeImmutableobjects to/from stringsBackedEnumTransformer: Convert backed enums to/from their scalar valueStringableTransformer: ConvertStringableobjects to/from stringsRelationTransformer: Handle ManyToOne-like relations โ ๏ธRelationsTransformer: Handle OneToMany-like relations โ ๏ธ
โ ๏ธ Since Loupe is not a relational database, it has no foreign key constraints: use this at your own risk!
Custom transformers
use Honey\ODM\Core\Config\TransformerMetadata; #[AsField(transformer: new TransformerMetadata( MyCustomTransformer::class, ['option1' => 'value1'], ))] public mixed $myProperty;
Advanced Usage
Custom repository
ObjectRepository (the default repository) is final โ compose ObjectRepositoryTrait into your
own class instead for domain-specific methods:
use Honey\ODM\Core\Criteria\Criteria; use Honey\ODM\Loupe\Repository\ObjectRepositoryInterface; use Honey\ODM\Loupe\Repository\ObjectRepositoryTrait; use function Honey\ODM\Core\Criteria\field; /** * @implements ObjectRepositoryInterface<Movie> */ final class MovieRepository implements ObjectRepositoryInterface { use ObjectRepositoryTrait; public function findReleasedAfter(int $year): iterable { return $this->findBy(Criteria::create()->where(field('year')->greaterThan($year))); } }
Register the repository with the Object Manager as early as possible in your application:
$movieRepository = new MovieRepository($objectManager, Movie::class); $objectManager->registerRepository(Movie::class, $movieRepository);
Events
Bring your own (PSR-14 compliant) event dispatcher, and hook your logic to lifecycle events:
use Honey\ODM\Core\Event\PrePersistEvent; use Honey\ODM\Loupe\ObjectManagerFactory; $eventDispatcher = new EventDispatcher(); // <-- Any PSR-14 compliant dispatcher $objectManager = ObjectManagerFactory::create(eventDispatcher: $eventDispatcher); $eventDispatcher->addListener(PrePersistEvent::class, function (PrePersistEvent $event) { var_dump($event->object); // <-- The object being persisted });
Using Loupe for tests without touching your production models
#[AsAttribute] and #[AsIndex] live in this package, so putting them on your entities makes
them a production dependency โ reading platform metadata instantiates the attribute class, which
is a fatal error if the package is only in require-dev.
To keep Loupe strictly a test-time backend, skip the attributes and hand
ObjectManagerFactory::create() a $configurator closure instead. It receives the configuration
derived from the class metadata plus that class's metadata, and returns the configuration to use:
use Honey\ODM\Core\Config\AsDocument; use Honey\ODM\Loupe\ObjectManagerFactory; use Loupe\Loupe\Configuration; $objectManager = ObjectManagerFactory::create( configurator: function (Configuration $configuration, AsDocument $classMetadata): Configuration { // Replay whatever your production backend already declares - here, the settings you // keep for Meilisearch indexes. $settings = json_decode(file_get_contents( sprintf('%s/config/indexes/%s.json', __DIR__, $classMetadata->collection), ), true); return $configuration ->withFilterableAttributes($settings['filterableAttributes']) ->withSortableAttributes($settings['sortableAttributes']) ->withSearchableAttributes($settings['searchableAttributes']); }, );
Your entities keep only the core's #[AsDocument] / #[AsField], and nothing in production
depends on this package.
Between tests, IndexRegistry::drop() forgets an in-memory index and deletes an on-disk one, so
each test starts from an empty collection:
$objectManager->transport->indexRegistry->drop($objectManager->getClassMetadata(Movie::class));
Differences from honey-odm/meilisearch
Loupe is Meilisearch-shaped, but it isn't Meilisearch, and a few structural differences leak through the adapter:
- One SQLite database per collection, not one server holding every index. There is no way to
share a connection between collections, so each mapped class gets its own
Loupeinstance and its own data directory. - Configuration is frozen at construction. There is no "push settings to a running index"
call like Meilisearch's โ
needsReindex()reports drift, but reacting to it (re-indexing) is the caller's decision, not this library's. - No
CONTAINS/STARTS_WITH/ENDS_WITH/EXISTS. Loupe has no substring matching, and its index-wide schema makesEXISTSmeaningless. - No group negation, handled transparently by the De Morgan pushdown described above โ Loupe's
grammar only allows
NOTimmediately beforeINandBETWEEN. BETWEENis numeric-only natively; string ranges fall back to AND-combined comparisons.- Geo filters take the attribute name โ
_geoRadius(attribute, โฆ)โ rather than a reserved_geofield. - No asynchronous flush options. SQLite writes are synchronous, so Meilisearch's
wait,flushTimeoutMsandflushCheckIntervalMshave no counterpart; the only flush option isflushBatchSize(defaulting to a single batch).
Testing
This package includes comprehensive test coverage using Pest PHP. Unlike the Meilisearch adapter, no external server is required, so the whole suite runs offline:
# Run tests composer tests:run # Check types composer types:check # Check code style composer style:check # Perform all checks at once composer ci:check
Code Quality
This project maintains high code quality standards:
- 100% test coverage requirement
- PHPStan level 9 static analysis
- PSR-12 code style, enforced by
phpcs - Pest PHP for testing
License
This project is licensed under the MIT License - see the LICENSE file for details.
Related Projects
- Honey ODM Core - The core ODM framework
- honey-odm/meilisearch - The sibling Meilisearch adapter
- Loupe - The Loupe search engine