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 ORMPaperORM
RelationsLazy loaded on access, or fetch-joined in DQLLoaded only with with() / has(), in one query
CascadeConfigurable persist / remove cascadeNone: you persist and remove each entity
Transactionsflush() opens oneYou open them yourself
Limit with one-to-many joinsNeeds the Paginator toolBuilt in: limit() counts root entities
Query languageDQL, QueryBuilderRepository fetcher, QueryBuilder, SQL
DatabasesManySQLite, MariaDB / MySQL
Installed (no dev)23 packages · ~171,000 PHP lines10 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.

ScenarioDoctrine ORMPaperORM
Insert 1,000 entities29.420.2
Find 1,000 entities by id44.044.2
Load all entities6.05.3
Load all as arrays2.81.8
Load with a one-to-many join67.545.8
Paginate with a one-to-many join21.39.9
Update 1,000 entities23.819.5
Delete 1,000 entities15.17.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