PHP 8.2+ · SQLite · MariaDB / MySQL · Symfony bundle
The ORM that does what you wrote, and nothing else.
Map your entities with attributes, query them through repositories, migrate your schema. No lazy loading, no cascade, no transaction you did not open. Every query runs because you asked for it.
How it works
Map. Migrate. Query.
Three steps, nothing hidden. Your entities are plain PHP classes, your schema is plain SQL files, your results are plain objects or arrays.
1. Map
Describe each table with attributes on a plain class: #[Entity], #[StringColumn], #[JoinColumn], #[OneToMany].
2. Migrate
paper:migration:diff compares your entities with the database and writes a .sql file. paper:migration:migrate runs it.
3. Query
findBy(), with('posts'), limit(20), toObject(). You choose the relations to load and the shape of the result.
Why PaperORM
Explicit by design.
An ORM should save you from writing SQL by hand, not hide what it sends to your database.
No surprise query
No lazy loading: a relation is loaded when you ask for it with with() or has(), in the same query. Nothing runs when you read a property.
Paginate one-to-many
limit(10) returns 10 users, even when each user brings 50 posts in the same join. Filter and sort on the relations, the page stays right.
Your transactions
flush() never opens a transaction behind your back. You wrap what you want, where you want, with PDO.
Small and fast
About 21,000 lines of PHP with its dependencies. Faster than or level with Doctrine on every scenario of the bundled benchmark.
Compare
Lighter than Doctrine, on purpose.
Doctrine is a complete, mature ORM. PaperORM keeps the part most applications use, and makes every step visible.
| Doctrine ORM | PaperORM | |
|---|---|---|
| Relations | Lazy loaded on access, or fetch-joined in DQL | Loaded only with with() / has(), in one query |
| Cascade | Configurable persist / remove cascade | None: you persist and remove each entity |
| Transactions | flush() opens one | You open them yourself |
| Limit with one-to-many joins | Needs the Paginator tool | Built in: limit() counts root entities |
| Query language | DQL, QueryBuilder | Repository fetcher, QueryBuilder, SQL |
| Databases | Many | SQLite, MariaDB / MySQL |
| Installed (no dev) | 23 packages · ~171,000 PHP lines | 10 packages · ~21,000 PHP lines |
Median time in milliseconds, 1,000 rows, SQLite in memory, PHP 8.2, one run of benchmark/bench.php. Run it yourself: the benchmark ships with the source.
| Scenario | Doctrine ORM | PaperORM |
|---|---|---|
| Insert 1,000 entities | 29.4 | 20.2 |
| Find 1,000 entities by id | 44.0 | 44.2 |
| Load all entities | 6.0 | 5.3 |
| Load all as arrays | 2.8 | 1.8 |
| Load with a one-to-many join | 67.5 | 45.8 |
| Paginate with a one-to-many join | 21.3 | 9.9 |
| Update 1,000 entities | 23.8 | 19.5 |
| Delete 1,000 entities | 15.1 | 7.6 |
Scope
What PaperORM will never do
Every rule below is a query you can predict by reading your own code.
- lazy-load a relation
- cascade a persist or a delete
- open a transaction for you
- invent a query language
- query when you read a property
- require a framework
Get PaperORM
One Composer command.
PaperORM works on its own, in any PHP 8.2+ project. On Symfony, add the bundle: configuration, services, console commands and profiler panel.
$ composer require michel/paper-orm # any PHP project $ composer require michel/paper-orm-bundle # Symfony