Documentation
Everything PaperORM does, in the order you need it. PaperORM never runs a query you did not ask for: no lazy loading, no cascade, no transaction opened for you.
1. Install
$ composer require michel/paper-orm
| Requirement | Version |
|---|---|
| PHP | 8.2 or later, with pdo, json, ctype, iconv |
| SQLite | pdo_sqlite |
| MariaDB / MySQL | pdo_mysql |
On Symfony, install the bundle instead: it brings PaperORM with it. See Symfony bundle.
2. Quick start
Create the entity manager once, from a DSN:
use Michel\PaperORM\EntityManager; use Michel\PaperORM\PaperConfiguration; $em = EntityManager::createFromConfig( PaperConfiguration::fromDsn('sqlite:///var/data/app.sqlite') );
Describe a table with attributes:
use Michel\PaperORM\Entity\EntityInterface; use Michel\PaperORM\Mapping\Entity; use Michel\PaperORM\Mapping\Column\{PrimaryKeyColumn, StringColumn, BoolColumn}; #[Entity(table: 'user')] class User implements EntityInterface { #[PrimaryKeyColumn] private ?int $id = null; #[StringColumn] private ?string $email = null; #[BoolColumn(name: 'is_active')] private bool $active = true; public function getPrimaryKeyValue(): ?int { return $this->id; } // getters and setters... }
Create the table (see Migrations), then write and read:
$user = (new User())->setEmail('jane@example.com'); $em->persist($user); $em->flush(); // INSERT, $user->getId() is set $users = $em->getRepository(User::class) ->findBy(['active' => true]) ->orderBy('email') ->limit(20) ->toObject(); // User[]
3. Configuration
From a DSN
| Database | DSN |
|---|---|
| SQLite file | sqlite:///absolute/path/app.sqlite |
| SQLite in memory | sqlite:///:memory: |
| MariaDB | mariadb://user:password@127.0.0.1:3306/app?charset=utf8mb4 |
| MySQL | mysql://user:password@127.0.0.1:3306/app?charset=utf8mb4 |
From an array
$config = PaperConfiguration::fromArray([
'driver' => 'mariadb', // sqlite, mariadb or mysql
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'app',
'user' => 'app',
'password' => 'secret',
'options' => ['charset' => 'utf8mb4'],
]);
For SQLite, pass 'path' => '/absolute/path/app.sqlite' or 'memory' => true.
Options
$config = PaperConfiguration::fromDsn($dsn, debug: true); // records every query $config->withLogger($psr3Logger); // any PSR-3 logger $config->withListener(PreCreateEvent::class, $listener); // see Events $em = EntityManager::createFromConfig($config);
$em->clear() between two jobs; the Symfony bundle does it for you.4. Entities
An entity is a plain class that implements EntityInterface (one method, getPrimaryKeyValue()) and carries the #[Entity] attribute.
#[Entity(table: 'post', repository: PostRepository::class)] // repository is optional
Columns
Each attribute takes the column name as first argument; by default it is the property name. All of them are in Michel\PaperORM\Mapping\Column.
| Attribute | PHP type | Main arguments |
|---|---|---|
PrimaryKeyColumn | ?int | Auto-incremented by the database. One per entity. |
StringColumn | ?string | length: 255, nullable, defaultValue, unique |
TextColumn | ?string | nullable, defaultValue |
IntColumn | ?int | nullable, defaultValue, unique |
FloatColumn | ?float | nullable, defaultValue, unique |
DecimalColumn | ?string | precision: 10, scale: 2, nullable, unique |
BoolColumn | bool | nullable, defaultValue |
DateColumn, DateTimeColumn | ?\DateTimeInterface | nullable |
JsonColumn | ?array | nullable, defaultValue |
BinaryColumn | ?string | nullable |
AnyColumn | any | Stored without conversion. |
TimestampColumn, SlugColumn, TokenColumn, UuidColumn, AutoIncrementColumn | Filled by PaperORM, see Generated values. |
Indexes
#[Entity(table: 'user')] #[Index(columns: ['email'], unique: true)] class User implements EntityInterface { /* ... */ }
Without attributes
Implement TableMetadataInterface to describe the table with static methods instead. Attributes and this interface give the same result.
class Comment implements EntityInterface, TableMetadataInterface { public static function getTableName(): string { return 'comment'; } public static function getRepositoryName(): ?string { return null; } public static function getIndexes(): array { return []; } public static function columnsMapping(): array { return [ (new PrimaryKeyColumn())->bindProperty('id'), (new TextColumn())->bindProperty('body'), (new JoinColumn('post_id', Post::class))->bindProperty('post'), ]; } // ... }
5. Relations
Many-to-one: JoinColumn
The side that holds the foreign key.
#[JoinColumn(name: 'user_id', targetEntity: User::class, nullable: true, onDelete: JoinColumn::SET_NULL)] private ?User $user = null;
onDelete and onUpdate become the foreign key rules of the database: NO_ACTION (default), RESTRICT, CASCADE, SET_NULL, SET_DEFAULT. The database applies them, never PaperORM. Add unique: true for a one-to-one.
One-to-many: OneToMany
The inverse side, a collection of the entities that point to this one.
#[OneToMany(targetEntity: Post::class, mappedBy: 'user')] private ObjectStorage $posts; public function __construct() { $this->posts = new ObjectStorage(); }
ObjectStorage (Michel\PaperORM\Collection) offers add(), remove(), first(), last(), find(), filter(), findPk(), toArray() and isEmpty().
with() or has(), $user->getPosts() is empty and $post->getUser() is null. See Repositories.6. Migrations
PaperORM compares your entities with the database and writes the difference as a plain .sql file, with an UP and a DOWN part. You read it, commit it, then run it.
use Michel\PaperORM\Collector\EntityDirCollector; use Michel\PaperORM\Migration\PaperMigration; use Michel\PaperORM\Tools\EntityExplorer; $migration = PaperMigration::create($em, 'mig_versions', __DIR__ . '/migrations'); // bootstrap() adds the internal tables of PaperORM (registry, sequences) $dirs = EntityDirCollector::bootstrap([__DIR__ . '/src/Entity'])->all(); $found = EntityExplorer::getEntities($dirs); $file = $migration->generateMigrationFromEntities([...$found['system'], ...$found['normal']]); // null when the database already matches $migration->migrate(); // runs every migration not yet applied
Each migration runs in a transaction and is recorded in the table you named (mig_versions). $migration->down($version) runs the DOWN part of one migration.
Console commands
| Command | Effect |
|---|---|
paper:migration:diff | Writes a migration file with the difference between entities and database. |
paper:migration:migrate | Runs the migrations not yet applied. |
paper:database:sync | Applies the difference directly, without a file. --no-execute only prints the SQL. For development. |
paper:database:create | Creates the database. --if-not-exists. |
paper:database:drop | Drops the database. Requires --force; development and test only. |
paper:show:tables | Lists the tables, or the columns of one table with --columns. |
paper:query:execute | Runs one SQL query and prints the result. |
With the Symfony bundle they are available in bin/console. Without a framework, run them with michel/console:
use Michel\Console\{CommandParser, CommandRunner, Output}; use Michel\PaperORM\Command\Migration\{MigrationDiffCommand, MigrationMigrateCommand}; $runner = new CommandRunner([ new MigrationDiffCommand($migration, EntityDirCollector::bootstrap([__DIR__ . '/src/Entity'])), new MigrationMigrateCommand($migration), ]); exit($runner->run(new CommandParser(), new Output()));
7. Insert, update, delete
$em->persist($post); // new entity: INSERT on flush $em->flush(); $post = $repository->find(1)->toObject(); $post->setTitle('New title'); $em->persist($post); // loaded entity: UPDATE of the changed columns only $em->flush(); $em->remove($post); // DELETE on flush $em->flush(); $em->flush($post); // writes this entity only
Entities loaded with toObject() track their changes: an update writes only the columns you modified, and nothing when you modified none.
onDelete) act.8. Generated values
These columns are filled by PaperORM when the entity is inserted (and updated, for onUpdated).
| Attribute | Value |
|---|---|
TimestampColumn(onCreated: true, onUpdated: true) | Current date and time. |
SlugColumn(from: ['title']) | Slug built from other properties: "Hello World" → hello-world. |
TokenColumn(length: 32) | Random hexadecimal token of 16, 32, 64 or 128 (default) characters. |
UuidColumn | Random identifier in the UUID format (36 characters). |
AutoIncrementColumn(key: 'invoice', pad: 6, prefix: 'INV-{YYYY}-') | Numbered sequence: INV-2026-000001, INV-2026-000002… |
The prefix of AutoIncrementColumn accepts {YYYY}, {YY}, {MM}, {DD}, {YMD}, {DATE}. Each distinct prefix keeps its own counter, so INV-{YYYY}- starts again at 1 every year. The counter is stored in the registry table of PaperORM: create it with your migrations (EntityDirCollector::bootstrap() adds it). Two processes never get the same number: the sequence is locked from its read to its increment.
9. Repositories
$em->getRepository(User::class) returns the repository of the entity. Each method returns a fetcher: chain what you need, then ask for a result.
$repository->find(42)->toObject(); // ?User $repository->findOneBy(['email' => $email])->toObject(); // ?User $repository->findBy(['active' => true])->toObject(); // User[] $repository->findBy()->toCount(); // int
Criteria
Keys are property names. Values decide the condition:
| Value | Condition |
|---|---|
'jane@example.com', 42, true | = |
[1, 2, 3] | IN (1, 2, 3) |
null | IS NULL |
'!NULL' | IS NOT NULL |
| an entity | = its primary key |
For other operators, use Expr (Michel\PaperORM\Expression\Expr): equal, notEqual, greaterThan, greaterThanEqual, lowerThan, lowerThanEqual, isNull, isNotNull, in, notIn. Values are always bound as parameters.
$repository->findBy()
->where(Expr::greaterThanEqual('createdAt', '2026-01-01'), Expr::notIn('id', [3, 7]))
->orderBy('createdAt', 'DESC')
->offset(40)->limit(20)
->toObject();
Relations
->with('posts') // LEFT JOIN: users, with their posts if any ->has('posts') // INNER JOIN: only users that have posts ->with('lastPost.comments') // follow a path of relations
Results
| Method | Returns |
|---|---|
toObject() | Entities that track their changes, ready to update. The same row gives the same object within an entity manager. |
toReadOnlyObject() | Plain entities, not tracked. Lighter, for display. |
toArray() | Arrays, relations nested under their property name. The fastest. |
toCount() | The number of matching rows. |
toLazyObject(), toLazyArray(), toLazyReadOnlyObject() | A closure that runs the query when you call it. |
first() turns any fetcher into a single result or null; find() and findOneBy() already do it.
Your own repository
final class PostRepository extends Repository { public function getEntityName(): string { return Post::class; } public function findPublished(): array { return $this->findBy(['published' => true])->orderBy('createdAt', 'DESC')->toObject(); } }
Declare it with #[Entity(table: 'post', repository: PostRepository::class)]. Without it, PaperORM gives the entity a default repository.
10. Pagination
A join with a one-to-many relation returns one row per child: a user with 50 posts is 50 rows. A plain SQL LIMIT 10 would return a fraction of one user.
PaperORM counts root entities, not rows:
$users = $repository->findBy(['active' => true]) ->with('posts') ->orderBy('email') ->offset(20)->limit(10) ->toObject(); // exactly 10 users, each with all their posts
- A first query selects the ids of the page, with your conditions and your order, including those on relations.
- A second query loads those entities and their relations:
WHERE id IN (...).
Without a one-to-many join, the limit goes straight to the database in one query. Both cases use the same plain SQL on every supported database: no window function, no DISTINCT.
11. Query builder
For queries the fetcher cannot express, take the query builder of the repository. Conditions use alias.property; the alias of the root entity comes from getPrimaryAlias().
use Michel\PaperORM\Query\QueryBuilder; $qb = $em->getRepository(User::class)->qb(); $u = $qb->getPrimaryAlias(); $users = $qb ->leftJoin($u, Post::class, 'posts') ->where("$u.active = :active", "$u.id IN (:ids)") ->setParam('active', true) ->setParam('ids', [1, 2, 3]) // a list gets one placeholder per value ->orderBy("$u.email") ->setMaxResults(20) ->getResult(QueryBuilder::HYDRATE_ARRAY);
| Method | Use |
|---|---|
leftJoin(), innerJoin() | Join a relation: from alias, target entity, property. |
where(), orderBy() | Conditions and order on alias.property. |
rawWhere() | A condition in plain SQL, with column names. |
setParam(), setParams() | Bound values; an entity gives its primary key. |
setFirstResult(), setMaxResults() | Same pagination as the fetcher. |
getResult(), getOneOrNullResult(), getCountResult() | Run it. Hydration: HYDRATE_OBJECT (default), HYDRATE_OBJECT_READONLY, HYDRATE_ARRAY. |
getResultIterator() | Yields the results one by one, for large exports. |
A query built twice with the same shape is turned into SQL only once per connection; only its parameters change.
12. Transactions
flush() never opens a transaction. You decide what must succeed or fail together:
$pdo = $em->getConnection()->getPdo(); $pdo->beginTransaction(); try { $em->persist($order); $em->persist($invoice); $em->flush(); $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); throw $e; }
13. Events
PaperORM dispatches PSR-14 events around each write. Register listeners on the configuration, before creating the entity manager:
use Michel\PaperORM\Event\Create\PreCreateEvent; $config->withListener(PreCreateEvent::class, function (PreCreateEvent $event) { $entity = $event->getEntity(); $em = $event->getEm(); });
| Event | When |
|---|---|
Create\PreCreateEvent, Create\PostCreateEvent | Before and after an INSERT. |
Update\PreUpdateEvent, Update\PostUpdateEvent | Before and after an UPDATE that changed something. |
Delete\PreDeleteEvent, Delete\PostDeleteEvent | Before and after a DELETE. |
14. Registry and sequences
A small key-value store in your database, in the internal table created by your migrations:
$em->registry()->set('maintenance', 'on'); $em->registry()->get('maintenance'); // 'on', or null $em->registry()->remove('maintenance');
Named counters, used by AutoIncrementColumn and available to you:
$em->sequence()->peek('ticket'); // next value, without using it $em->sequence()->increment('ticket'); // use it $em->sequence()->reset('ticket');
Locker with a shared lock handler to PaperSequenceManager.15. Debugging
With debug: true, the connection records every query with its parameters and duration, and sends it to the logger given to withLogger().
$debugger = $em->getConnection()->getPdo()->getSqlDebugger(); $debugger->getQueries(); $debugger->getQueryCount(); $debugger->getTotalTime();
Leave debug off in production: recording every query costs time and memory.
16. Symfony bundle
michel/paper-orm-bundle integrates PaperORM into Symfony 5.4, 6 and 7.
$ composer require michel/paper-orm-bundle
// config/bundles.php Michel\PaperORMBundle\PaperORMBundle::class => ['all' => true],
# config/packages/paper_orm.yaml paper_orm: dsn: '%env(resolve:DATABASE_URL)%' debug: '%kernel.debug%'
| Option | Default | Meaning |
|---|---|---|
dsn | required | Database DSN, see Configuration. |
debug | false | Record the queries, shown in the profiler. |
logger | paper.logger | Service id of a PSR-3 logger. paper.logger writes to var/log/paper.log. null disables it. |
entity_dir | %kernel.project_dir%/src/Entity | Where your entities are. |
migrations_dir | %kernel.project_dir%/migrations | Where migration files are written. |
migrations_table | mig_versions | Table recording the applied migrations. |
proxy_autoload | false | Set to true if you store loaded entities in the session. |
Inject Michel\PaperORM\EntityManagerInterface in your services. The bundle also provides:
bin/console paper:migration:diff,paper:migration:migrate,paper:database:create,paper:database:drop,paper:database:sync;- a profiler panel listing the queries of the request;
- a reset of the entity manager between two requests in long-running workers (FrankenPHP, RoadRunner, Messenger).