Download the PHP package honey-odm/loupe without Composer
On this page you can find all versions of the php package honey-odm/loupe. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Informations about the package loupe
๐ Honey / Loupe
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
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:
The same annotated class works on any other Honey ODM implementation โ the #[AsAttribute] /
#[AsIndex] flags are simply ignored there.
2. Configure the Object Manager
Before you can persist anything, apply the schema โ this creates each collection's data directory and database:
3. Basic operations
4. Persist your data
The Object Manager (not the repository) owns persistence:
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:
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:
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:
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:
dropSchema() deletes every collection's data directory (or discards the in-memory instance):
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
Advanced Usage
Custom repository
ObjectRepository (the default repository) is final โ compose ObjectRepositoryTrait into your
own class instead for domain-specific methods:
Register the repository with the Object Manager as early as possible in your application:
Events
Bring your own (PSR-14 compliant) event dispatcher, and hook your logic to lifecycle events:
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:
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:
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:
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
All versions of loupe with dependencies
honey-odm/core Version ^1.0@beta
loupe/loupe Version ^1.0
symfony/options-resolver Version ^7.2|~8.0