Download the PHP package honey-odm/core without Composer
On this page you can find all versions of the php package honey-odm/core. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Informations about the package core
π Honey / ODM
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
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:
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:
Retrieve them from either level:
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:
Passing a plain list of class names instead warms up the registry eagerly:
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.
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:
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:
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):
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:
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:
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:
Holding a pre-built value object? Use the node directly:
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:
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:
Using the Object Manager
That's the only mandatory argument. Everything else is optional and has a sensible default:
Persisting and retrieving
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:
You can also register a repository for a single class:
Repositories implement ObjectRepositoryInterface:
Identity management
Objects are tracked and deduplicated per class + id:
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.
Change detection compares the object's current serialization with the one taken when it was hydrated (or when it was last flushed). Both sides therefore go through the same mapping pipeline: reading documents never schedules a write, even when the stored document holds attributes your class doesn't map, or when a transformer can't round-trip its value (a relation pointing to a missing document, for instance).
Values are compared strictly: null, '', [], 0 and false are distinct from one another, so a null -> ''
change is written instead of being silently dropped. Objects are the exception β they are compared by value, so two
equivalent value objects don't count as a change.
An id has exactly one managed instance: persisting a fresh object carrying the id of an already managed one detaches the latter, so the last persisted instance wins. Without it, both would be handed over to the transport within the same batch, and the surviving document would be picked by iteration order.
Objects which were loaded but never accessed stay lazy β they can't be dirty, so they're skipped when changesets are
computed. ObjectManager::getIdentifier() returns an object's id without initializing it, which is what the
transformers and transports use to resolve relations and deletions.
Events
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:
Then register it in the transformers container passed to the mapper:
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:
Its $storage property is public, so fixtures can be seeded without going through a flush β documents are indexed by
collection, then by id:
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
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
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)
4. Wire everything
Contributing
We welcome contributions! Here's how to get started:
Development Setup
-
Clone the repository:
-
Install dependencies:
- Run checks:
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:
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.
All versions of core with dependencies
psr/event-dispatcher Version ^1.0
symfony/property-access Version ~7.0|~8.0
bentools/reflection-plus Version ^1.0
psr/container Version ^2.0
bentools/iterable-functions Version ^2.3