honey-odm / core
Requires
- php: >=8.4
- bentools/iterable-functions: ^2.3
- bentools/reflection-plus: ^1.0
- psr/container: ^2.0
- psr/event-dispatcher: ^1.0
- symfony/property-access: ~7.0|~8.0
Requires (Dev)
- doctrine/collections: ^2.3
- friendsofphp/php-cs-fixer: ^3.88
- pestphp/pest: ^4.1
- phpstan/phpstan: ^2.1
- squizlabs/php_codesniffer: ^4.0
- symfony/uid: ^7.3|~8.0
- symfony/var-dumper: ^7.2|~8.0
This package is auto-updated.
Last update: 2026-07-22 14:10:53 UTC
README
A framework-agnostic, core foundation library for building modern Object Document Mappers (ODM) in PHP.
Overview
Honey ODM provides the essential components and patterns needed to build robust ODMs on top of any data source: REST APIs, search engines, NoSQL databases, or any custom storage backend.
The mapping layer is portable: a class annotated for Honey ODM can run on any implementation (Meilisearch, SQLite, Elasticsearch, ...) without being re-mapped. Everything platform-specific lives in dedicated attributes, and querying goes through a generic, compilable criteria model.
Key Features
- Portable mapping:
#[AsDocument]/#[AsField]are core attributes — no subclassing, no per-implementation attributes - Platform metadata: implementations ship their own attributes, placed alongside the core ones
- Platform-agnostic criteria: a fluent query builder + expression AST that each transport compiles to its own dialect
- Built-in property transformers: dates, backed enums, relations, stringable value objects
- Event system: full lifecycle events (pre/post persist, update, remove, load)
- Identity management: objects are tracked, deduplicated, and lazily hydrated
- Unit of Work: change tracking and batched insert / update / delete operations
- In-memory transport: run your integration tests against the full API, without a database
Requirements
- PHP 8.4 or higher
- (Optional) PSR-14 Event Dispatcher implementation
- (Optional) PSR-11 Container implementation
Installation
composer require honey-odm/core
The library ships a polyfill for the native PHP 8.6
SortDirectionenum, so sorting works on PHP 8.4+.
Glossary
- Class Metadata (
#[AsDocument]): metadata about a document class (collection name, platform metadata, properties) - Property Metadata (
#[AsField]): metadata about a document property (field name, primary key, transformer) - Platform Metadata: implementation-specific configuration, attached to a class or a property
- Transport: handles communication with your data source, and compiles criteria into its native query language
- Object Manager: central component that orchestrates all ODM operations and events
- Unit of Work: tracks changes and scheduled actions (insert, update, delete). It is destructed and recreated after each flush.
- Object Repository: provides repository pattern methods for retrieving documents as objects
Mapping your documents
Mapping relies on two final core attributes:
namespace App; use DateTimeInterface; use Honey\ODM\Core\Config\AsDocument; use Honey\ODM\Core\Config\AsField; use Honey\ODM\Core\Config\TransformerMetadata; use Honey\ODM\Core\Mapper\PropertyTransformer\DateTimeImmutableTransformer; use Honey\ODM\Core\Mapper\PropertyTransformer\RelationTransformer; #[AsDocument(collection: 'books')] final class Book { public function __construct( #[AsField(primary: true)] public string $id, #[AsField(name: 'title')] public string $name, #[AsField(name: 'author_id', transformer: RelationTransformer::class)] public ?Author $author = null, #[AsField(name: 'published_at', transformer: new TransformerMetadata(DateTimeImmutableTransformer::class, ['from_format' => 'Y-m-d', 'to_format' => 'Y-m-d']))] public ?DateTimeInterface $publishedAt = null, ) { } }
collectionis the logical name of the storage container (index, table, bucket, endpoint, ... — up to the implementation).AsField::$namedefaults to the PHP property name. The resolved value is exposed asAsField::$fieldName.- Exactly one property must be flagged
primary: true, otherwise metadata reading throws. transformeraccepts either a service id (usually a class name) or aTransformerMetadatainstance when you need options.
Platform-specific metadata
Implementations provide their own attributes implementing PlatformMetadataInterface. They are placed alongside the
core attributes and collected by the registry:
use Honey\ODM\Core\Config\AsDocument; use Honey\ODM\Core\Config\AsField; use Honey\ODM\Meilisearch\Config as Meili; #[AsDocument(collection: 'books')] #[Meili\Document(rankingRules: ['words', 'typo', 'sort'])] final class Book { public function __construct( #[AsField(primary: true)] #[Meili\Attribute(filterable: true, sortable: true)] public int $id, ) { } }
Retrieve them from either level:
$classMetadata = $objectManager->getClassMetadata(Book::class); $classMetadata->getPlatformMetadata(Meili\Document::class)?->rankingRules; $classMetadata->getPropertyMetadata('id')->getPlatformMetadata(Meili\Attribute::class)?->filterable;
The same class can therefore carry metadata for several platforms at once — each implementation simply ignores what isn't addressed to it.
External metadata
Classes you cannot annotate (third-party classes, anonymous classes) can be registered by providing their
AsDocument instance to the registry:
use Honey\ODM\Core\Config\AsDocument; use Honey\ODM\Core\Config\ClassMetadataRegistry; $registry = new ClassMetadataRegistry(configurations: [ Book::class => new AsDocument(collection: 'books'), ]);
Passing a plain list of class names instead warms up the registry eagerly:
$registry = new ClassMetadataRegistry(configurations: [Book::class, Author::class]);
Otherwise, metadata is read lazily on first access. Note that external metadata replaces the class-level attribute
only: properties are still read from their #[AsField] attributes.
Querying
Queries are expressed with the generic Criteria object, in PHP property names. Each transport compiles them into
its own native query language.
use Honey\ODM\Core\Criteria\Criteria; use function Honey\ODM\Core\Criteria\field; use function Honey\ODM\Core\Criteria\not; $criteria = Criteria::create() ->search('gatsby') ->where(field('publishedAt')->greaterThan('1920-01-01')) ->andWhere(field('author')->in([1, 2, 3])) ->orWhere(not(field('name')->startsWith('Draft:'))) ->orderBy('publishedAt', 'desc') ->orderBy('name') ->limit(20) ->offset(40); $books = $objectManager->getRepository(Book::class)->findBy($criteria);
Properties are named after their PHP name (publishedAt, not published_at), but values are compared against
their storage representation: property transformers are not applied to criteria values. In the example above,
field('author') is matched against author ids, since that's what the author_id field holds.
For simple equality filters, an array is enough — it is AND-combined:
$repository->findBy(['name' => 'The Great Gatsby']); // shorthand for Criteria::fromArray([...]) $repository->findOneBy(['id' => '123']); $repository->findAll();
Expressions
The expression tree is built from three node types, all implementing ExpressionInterface:
| Node | Built with |
|---|---|
Comparison |
field('property')->equals($value), or new Comparison(...) |
CompositeExpression |
CompositeExpression::and(...) / ::or(...) |
Negation |
not($expression) |
Available operators (Honey\ODM\Core\Criteria\Operator):
Field method |
Operator | Opposite |
|---|---|---|
equals($value) |
EQUALS |
notEquals($value) * |
greaterThan($value) |
GREATER_THAN |
— |
greaterThanOrEquals($value) |
GREATER_THAN_OR_EQUALS |
— |
lessThan($value) |
LESS_THAN |
— |
lessThanOrEquals($value) |
LESS_THAN_OR_EQUALS |
— |
between($left, $right, $includeLeft = true, $includeRight = true) |
BETWEEN |
notBetween(...) |
in(array $values) |
IN |
notIn(array $values) * |
hasAll(array $values) |
HAS_ALL |
notHasAll(array $values) |
contains(string $value) |
CONTAINS |
notContains($value) |
startsWith(string $value) |
STARTS_WITH |
notStartsWith($value) |
endsWith(string $value) |
ENDS_WITH |
notEndsWith($value) |
isNull() |
IS_NULL |
isNotNull() * |
exists() |
EXISTS |
notExists() |
isEmpty() |
IS_EMPTY |
isNotEmpty() |
withinGeoRadius($lat, $lon, $meters) |
WITHIN_GEO_RADIUS |
outsideGeoRadius(...) |
withinGeoBoundingBox($swLat, $swLon, $neLat, $neLon) |
WITHIN_GEO_BOUNDING_BOX |
outsideGeoBoundingBox(...) |
search() is a full-text search term, only meaningful on search-capable platforms.
Only the three starred methods are operators of their own (NOT_EQUALS, NOT_IN, IS_NOT_NULL), because every
platform spells them natively. All the other not* / outside* methods return a Negation wrapping their positive
counterpart — field('name')->notContains('draft') is exactly not(field('name')->contains('draft')). Transports get
them for free, and adapter authors have nothing to implement beyond Negation itself.
⚠️ A negation is not the mirror of its operator on absent values: notBetween(10, 100) and outsideGeoRadius(...)
match documents holding no value at all, since those don't match the positive form either.
Criteriais mutable and fluent:where()replaces the current filter,andWhere()/orWhere()combine with it. Clone it if you want to derive several queries from a common base.
Presence, emptiness and nullity
Three distinct questions, three operators:
field('summary')->exists(); // the key is present in the document, whatever its value - including null field('summary')->isNotNull(); // the key is present AND its value is not null field('summary')->isEmpty(); // the value is null, '', [] or {}
A document whose summary is explicitly null satisfies exists() and isEmpty(), but fails isNotNull().
Ranges
between() builds a single BETWEEN node carrying a Range value object, so adapters can emit native range syntax
rather than a pair of comparisons. Inclusivity is per-side, and either bound may be null (but not both):
field('price')->between(10, 100); // 10 <= price <= 100 field('price')->between(10, 100, includeRight: false); // 10 <= price < 100 field('price')->between(null, 100); // price <= 100
A null field value never matches a range.
Sets
On an array field, in() matches when at least one of the given values is held, hasAll() when all of them
are:
field('tags')->in(['monument', 'paris']); // tagged monument OR paris field('tags')->hasAll(['monument', 'paris']); // tagged monument AND paris
Their opposites are easy to mix up, so here they are side by side:
tags |
in([a, b]) |
notIn([a, b]) |
hasAll([a, b]) |
notHasAll([a, b]) |
|---|---|---|---|---|
['a', 'b'] |
✅ | ❌ | ✅ | ❌ |
['a'] |
✅ | ❌ | ❌ | ✅ |
['c'] |
❌ | ✅ | ❌ | ✅ |
notIn() means none of them; notHasAll() means not all of them.
contains() / startsWith() / endsWith() are substring operators on string fields — don't confuse
contains('paris') with hasAll(['paris']).
Geo
Geo filters carry value objects from Honey\ODM\Core\Criteria\Geo, and distances are always in meters:
field('coordinates')->withinGeoRadius(48.8566, 2.3522, 5000); // within 5 km of Paris field('coordinates')->withinGeoBoundingBox(48.80, 2.22, 48.90, 2.47); // swLat, swLon, neLat, neLon
Bounding boxes go from the south-west (min) corner to the north-east (max) one, following GeoJSON, PostGIS, OGC/WMS, Leaflet and Google Maps. Platforms expecting another corner pair — Elasticsearch takes north-west / south-east, Meilisearch north-east / south-west — convert on their side. A box whose west longitude is greater than its east longitude legitimately crosses the antimeridian.
Coordinates, Radius and BoundingBox validate their input, so an impossible query fails at build time rather
than at the storage layer:
use Honey\ODM\Core\Criteria\Geo\BoundingBox; use Honey\ODM\Core\Criteria\Geo\Coordinates; use Honey\ODM\Core\Criteria\Geo\Radius; new Coordinates(91.0, 0.0); // InvalidArgumentException: Invalid latitude new Radius(new Coordinates(48.85, 2.35), -1); // InvalidArgumentException: Radius must be greater than 0 new BoundingBox(new Coordinates(48.9, 2.2), new Coordinates(48.8, 2.4)); // InvalidArgumentException
Holding a pre-built value object? Use the node directly:
new Comparison('coordinates', Operator::WITHIN_GEO_RADIUS, $radius);
Platform-specific criteria
Some queries have no portable form at all: vector search, image similarity, a matching strategy… Rather than forcing
you to subclass Criteria, it carries a free-form metadata bag that transports read at will:
$criteria = Criteria::create() ->search('a bee on a flower') ->metadata('vector', $embedding) ->metadata('semanticRatio', 0.9);
A transport that knows the key uses it; one that doesn't ignores it silently — this is opt-in, so unlike an unsupported operator it never throws. Keys are yours; prefix them if you target several platforms at once.
Capability mismatches
No platform supports everything. When a transport cannot compile an expression, an operator or a feature, it throws
UnsupportedExpressionException:
use Honey\ODM\Core\Criteria\UnsupportedExpressionException; throw UnsupportedExpressionException::expression($expression); throw UnsupportedExpressionException::operator(Operator::STARTS_WITH); throw UnsupportedExpressionException::feature('offset');
Using the Object Manager
use Honey\ODM\Core\Manager\ObjectManager; $objectManager = new ObjectManager(new MyTransport(...));
That's the only mandatory argument. Everything else is optional and has a sensible default:
$objectManager = new ObjectManager( transport: $transport, classMetadataRegistry: new ClassMetadataRegistry(), // default documentMapper: new DocumentMapper(), // default eventDispatcher: $psr14EventDispatcher, // defaults to a no-op dispatcher defaultFlushOptions: [], // implementation-specific options passed to the transport repositoryFactory: null, // Closure(ObjectManager, class-string): ObjectRepositoryInterface );
Persisting and retrieving
$book = new Book(id: '1', name: 'The Great Gatsby'); $objectManager->persist($book); $objectManager->flush(); $objectManager->remove($book); $objectManager->flush(['wait' => true]); // options are merged with $defaultFlushOptions $book = $objectManager->find(Book::class, '1'); $books = $objectManager->getRepository(Book::class)->findBy(['name' => 'The Great Gatsby']); $objectManager->clear(); // detaches everything and resets the Unit of Work
Objects returned by find() / repositories are lazy ghosts: the document is only mapped to the object when one of
its properties is actually accessed.
Repositories
By default, getRepository() returns the generic ObjectRepository. Implementations exposing native query
capabilities can provide their own default through the repositoryFactory closure:
$objectManager = new ObjectManager( transport: $transport, repositoryFactory: fn (ObjectManager $om, string $className) => new MyRepository($om, $className), );
You can also register a repository for a single class:
$objectManager->registerRepository(Book::class, new BookRepository($objectManager, Book::class));
Repositories implement ObjectRepositoryInterface:
interface ObjectRepositoryInterface { public function findBy(Criteria|array|null $criteria): iterable; public function findAll(): iterable; public function findOneBy(Criteria|array $criteria): ?object; public function find(mixed $id): ?object; }
Identity management
Objects are tracked and deduplicated per class + id:
$book1 = $objectManager->find(Book::class, '123'); $book2 = $objectManager->find(Book::class, '123'); var_dump($book1 === $book2); // true - same instance returned
Changes made on managed objects are detected at flush time by the Unit of Work — you don't need to persist() an
object that is already managed.
Events
use Honey\ODM\Core\Event\PrePersistEvent; $eventDispatcher->addListener(PrePersistEvent::class, function (PrePersistEvent $event) { $event->object->createdAt = new DateTimeImmutable(); });
Available events:
PrePersistEvent/PostPersistEventPreUpdateEvent/PostUpdateEventPreRemoveEvent/PostRemoveEventPostLoadEvent(when an object is hydrated from the persistence layer — also exposes the raw$document)
Pre-flush events may modify objects: changesets are recomputed until they stabilize. Calling flush() from within a
listener is a no-op, to prevent recursion.
Property transformers
Transformers convert values between the storage representation and PHP:
| Transformer | Purpose | Options |
|---|---|---|
DateTimeImmutableTransformer |
Dates | from_format, from_tz, to_format, to_tz, to_type |
BackedEnumTransformer |
Backed enums (target class inferred from the property type) | target_class |
RelationTransformer |
To-one relation, stored as the related document id | target_class |
RelationsTransformer |
To-many relation, stored as a list of ids | target_class (required) |
StringableTransformer |
Value objects exposing fromString() and __toString() (e.g. Ulid) |
— |
Writing your own is a matter of implementing PropertyTransformerInterface:
use Honey\ODM\Core\Config\AsField; use Honey\ODM\Core\Mapper\MappingContextInterface; use Honey\ODM\Core\Mapper\PropertyTransformer\PropertyTransformerInterface; final class MoneyTransformer implements PropertyTransformerInterface { public function fromDocument(mixed $value, AsField $propertyMetadata, MappingContextInterface $context): ?Money { return null === $value ? null : Money::fromCents($value); } public function toDocument(mixed $value, AsField $propertyMetadata, MappingContextInterface $context): ?int { return $value?->cents; } }
Then register it in the transformers container passed to the mapper:
use Honey\ODM\Core\Mapper\DocumentMapper; use Honey\ODM\Core\Mapper\PropertyTransformer\PropertyTransformers; $transformers = new PropertyTransformers(); $transformers->register(new MoneyTransformer()); $objectManager = new ObjectManager( transport: $transport, documentMapper: new DocumentMapper(transformers: $transformers), );
PropertyTransformers is a PSR-11 container keyed by class name — any other PSR-11 container will do.
Testing without a database
InMemoryTransport is a complete transport backed by a plain array. Swap it in and your tests exercise the real
object manager — mapping, transformers, identity map, unit of work, events — with no service to boot:
use Honey\ODM\Core\Manager\ObjectManager; use Honey\ODM\Core\Transport\InMemoryTransport; $transport = new InMemoryTransport(); $objectManager = new ObjectManager($transport); $objectManager->persist(new Book('123', 'The Great Gatsby')); $objectManager->flush(); $books = $objectManager->getRepository(Book::class)->findBy( Criteria::create()->where(field('name')->contains('Gatsby')), );
Its $storage property is public, so fixtures can be seeded without going through a flush — documents are indexed by
collection, then by id:
$transport->storage['books'] = [ '123' => ['id' => '123', 'title' => 'The Great Gatsby', 'author_id' => 1], ];
It supports every operator of the criteria API, which also makes it the executable specification of what a transport is supposed to do. Its filtering is naive (it walks the whole collection in PHP) — it's built for correctness in tests, not for volume.
search() matches literal substrings across every string field, ignoring case and diacritics — cafe finds Café.
No typo tolerance, no stemming, no ranking, and that's deliberate: a double that approximated a real engine's
relevance would let tests pass locally and fail in production. The diacritics folding needs ext-intl, which the
package doesn't require — searching without it throws, everything else works.
Building your own ODM
Since metadata, mapping, criteria, repositories and the object manager are all provided by the core, an implementation boils down to one transport, plus optional platform metadata attributes.
1. Implement the transport
interface TransportInterface { public function flushPendingOperations(UnitOfWork $unitOfWork, array $flushOptions = []): void; /** * @param AsDocument<object> $classMetadata * @return iterable<array<string, mixed>> * @throws UnsupportedExpressionException */ public function retrieveDocuments(AsDocument $classMetadata, Criteria $criteria): iterable; /** * @param AsDocument<object> $classMetadata * @return array<string, mixed>|null */ public function retrieveDocumentById(AsDocument $classMetadata, mixed $id): ?array; }
Important:
- Documents are exchanged as associative arrays. The transport never deals with objects — mapping is the mapper's job.
retrieveDocuments()may return any iterable: a plain array, aGenerator, or a richer collection carrying facets, aggregations, etc.retrieveDocuments()is where you compile the genericCriteriainto your native query language. Use$classMetadata->getFieldName($property)to translate PHP property names into storage-side field names, and throwUnsupportedExpressionExceptionfor anything your platform can't express.- In
flushPendingOperations(), read the Unit of Work for scheduled operations and perform them.
src/Transport/InMemoryTransport.php is a complete reference implementation (filters, negation, composite
expressions, multi-key sorting, pagination, search) — a good starting point for adapter authors.
2. Flushing pending operations
use Honey\ODM\Core\Mapper\MappingContext; use Honey\ODM\Core\Transport\TransportInterface; use Honey\ODM\Core\UnitOfWork\UnitOfWork; final class RestTransport implements TransportInterface { public function __construct( private ClientInterface $httpClient, private string $baseUrl, ) { } public function flushPendingOperations(UnitOfWork $unitOfWork, array $flushOptions = []): void { $objectManager = $unitOfWork->objectManager; $registry = $objectManager->classMetadataRegistry; $mapper = $objectManager->documentMapper; foreach ($unitOfWork->getPendingUpserts() as $object) { $classMetadata = $registry->getClassMetadata($object::class); $context = new MappingContext($classMetadata, $objectManager, $object, []); $document = $mapper->objectToDocument($object, [], $context); $id = $registry->getIdFromObject($object); $endpoint = $this->baseUrl . '/' . $classMetadata->collection; $this->httpClient->put("{$endpoint}/{$id}", ['json' => $document]); } foreach ($unitOfWork->getPendingDeletes() as $object) { $classMetadata = $registry->getClassMetadata($object::class); $id = $registry->getIdFromObject($object); $this->httpClient->delete("{$this->baseUrl}/{$classMetadata->collection}/{$id}"); } } // retrieveDocuments() / retrieveDocumentById() omitted for brevity }
The Unit of Work exposes getPendingInserts(), getPendingUpdates(), getPendingUpserts(), getPendingDeletes(),
getChangedObjects() and getPendingOperation($object) — use whichever granularity your backend needs (an API with a
dedicated POST for creations, a bulk endpoint, ...).
3. Add platform metadata (optional)
namespace MyODM\Config; use Attribute; use Honey\ODM\Core\Config\PlatformMetadataInterface; #[Attribute(Attribute::TARGET_CLASS)] final readonly class Collection implements PlatformMetadataInterface { public function __construct( public ?int $shards = null, ) { } }
4. Wire everything
$objectManager = new ObjectManager(new RestTransport($httpClient, 'https://api.example.com')); $book = new Book(id: '1', name: 'The Great Gatsby'); $objectManager->persist($book); $objectManager->flush(); // HTTP PUT /books/1 $foundBook = $objectManager->find(Book::class, '1'); // HTTP GET /books/1
Contributing
We welcome contributions! Here's how to get started:
Development Setup
- Clone the repository:
git clone https://github.com/bpolaszek/honey-odm.git
cd honey-odm
- Install dependencies:
composer install
- Run checks:
composer ci:check
Testing
The library uses Pest for testing. Tests are located in the tests/ directory:
tests/Unit/- Unit teststests/Behavior/- Behavioral teststests/Implementation/- Example documents and services (great for understanding usage patterns)
Run the full test suite:
composer tests:run
Code Standards
- Follow PSR-12 coding standards
- Use strict types (
declare(strict_types=1)) - Maintain 100% test coverage
- Use PHPStan level 9 for static analysis
Submitting Changes
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes with tests
- Ensure all checks pass (
composer ci:check) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Reporting Issues
Please use GitHub Issues to report bugs or request features. Include:
- PHP version
- Library version
- Clear description of the issue
- Code examples to reproduce the problem
Known Implementations
- honey-odm/meilisearch - A Meilisearch ODM
License
MIT.