honey-odm/loupe

Loupe (SQLite) adapter for Honey ODM

Maintainers

Package info

github.com/bpolaszek/honey-loupe

pkg:composer/honey-odm/loupe

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0-beta1 2026-08-29 06:29 UTC

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 Criteria API, or native Loupe SearchParameters / BrowseParameters for 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/LIKE operator 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: Convert DateTimeImmutable objects to/from strings
  • BackedEnumTransformer: Convert backed enums to/from their scalar value
  • StringableTransformer: Convert Stringable objects to/from strings
  • RelationTransformer: 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 Loupe instance 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 makes EXISTS meaningless.
  • No group negation, handled transparently by the De Morgan pushdown described above โ€” Loupe's grammar only allows NOT immediately before IN and BETWEEN.
  • BETWEEN is numeric-only natively; string ranges fall back to AND-combined comparisons.
  • Geo filters take the attribute name โ€” _geoRadius(attribute, โ€ฆ) โ€” rather than a reserved _geo field.
  • No asynchronous flush options. SQLite writes are synchronous, so Meilisearch's wait, flushTimeoutMs and flushCheckIntervalMs have no counterpart; the only flush option is flushBatchSize (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