xphp-kanon / core
Store-agnostic, reified, immutable typed data-mapper contracts for PHP, powered by xphp monomorphization (real generics with declaration-site variance).
Package info
Language:Makefile
pkg:composer/xphp-kanon/core
Requires
- php: ^8.4.0
- xphp-lang/collections: ^0.1.0
Requires (Dev)
- infection/infection: ^0.33
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^13.0
- xphp-lang/xphp: 0.3.0
This package is not auto-updated.
Last update: 2026-07-26 17:48:12 UTC
README
Store-agnostic, reified, immutable typed data-mapper contracts for PHP, built on xphp monomorphization — real generics with declaration-site variance, compiled to concrete PHP classes.
This is the foundation of the Kanon data mapper: the contracts a store adapter implements. It owns no
store dependency — only xphp-lang/collections for
ImmutableList/OrderedCollection — and it is the ecosystem's first cross-package .xphp consumer.
What it is
The read/write contracts, the query value object, and the one shared behavior:
| Type | Description |
|---|---|
ReadRepository<out T> |
The read contract: find(int|string): ?T, all(): OrderedCollection<T>, findBy(Criteria): OrderedCollection<T>, count(Criteria): int. Covariant. |
WriteRepository<in T> |
The write contract: save(T): void, delete(T): void. Contravariant. |
Repository<T> |
The full read+write contract — extends ReadRepository<T>, WriteRepository<T>. Invariant. |
Hydrator<out T> |
Turns one raw record (array<string, mixed>) into one entity T. Covariant. |
RecordMapping |
collect::<T>(iterable $records, Hydrator<T>): ImmutableList<T> — hydrate a stream of records into a typed immutable list. |
Query\Criteria |
An immutable, store-agnostic query: equality filters + ordering + a limit/offset window. |
Query\Order |
One ordering term (field, direction) of a Criteria. |
A store adapter (a future xphp-kanon/sql built on Doctrine DBAL, later a document adapter) implements
Repository/Hydrator and translates Criteria to its store; a consumer that needs only reads or only
writes depends on the narrower ReadRepository/WriteRepository. Absence is ?T, failure is a native
exception, and list results are immutable.
What it is NOT
- Not the mapper itself — that is this core plus a store adapter. This package has no store.
- Not an ORM — no identity map, no unit of work, no change tracking, no lazy associations. That tier is a separate, future package.
- No functional layer — absence is
?Tand failure is an exception, notOption/Result/Either. - Not a unified query language —
Criteriais the common subset every store expresses the same way (equality/order/window); richer, store-specific queries belong to an adapter.
Variance
ReadRepository and Hydrator are covariant in the entity type — a ReadRepository<Book> is usable wherever a
ReadRepository<Product> is expected (with Book extends Product):
// A ReadRepository<Book> flows into a position typed for the supertype Product, // and its read methods are called through the widened binding. function firstName(ReadRepository::<Product> $repo): ?string { return $repo->all()->first()?->name; }
List-valued reads return the OrderedCollection<T> interface (not a concrete list) precisely so this
upcast stays sound at runtime — an interface's specializations carry the covariant edge that a concrete
container return does not.
WriteRepository is the mirror image — contravariant (in T): a WriteRepository<Product> is usable
wherever a WriteRepository<Book> is expected (a writer of the supertype accepts the subtype). Combining
both, Repository<T> is invariant. See docs/caveats.md,
docs/adr/0001-store-agnostic-read-contracts.md, and
docs/adr/0006-write-tier.md.
Using it
The package ships .xphp templates; concrete classes are generated when you compile your own code
(which supplies the concrete entity types) together with these contracts. Point your xphp.json at your
installed xphp packages via include, so the compiler pulls in these contracts and collections. The
vendor/*/* glob discovers every installed xphp package (this core and collections) automatically:
{
"sources": ["src"],
"target": "build",
"cache": "cache",
"include": ["vendor/*/*"]
}
Construction and static generics are turbofish-required — the type argument is explicit, not inferred from the call's arguments:
$books = RecordMapping::collect::<Article>($conn->fetchAllAssociative($sql), $articleHydrator);
See the worked example in examples/Demo.xphp. Locally:
make build # compile the contract templates make demo # compile + run the example make test/unit # behaviour tests, including covariant-upcast assertions make ci # check + build + tests + PHPStan (level 9) + mutation (100% MSI) + demo
Caveats
Turbofish-required construction, read-only scope, Criteria as store-agnostic data, and the requirement
that entity types be authored as .xphp in the compiled source set. See
docs/caveats.md. Design decisions are recorded under docs/adr/.
License
MIT.