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
RequirementVersion
PHP8.2 or later, with pdo, json, ctype, iconv
SQLitepdo_sqlite
MariaDB / MySQLpdo_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

DatabaseDSN
SQLite filesqlite:///absolute/path/app.sqlite
SQLite in memorysqlite:///:memory:
MariaDBmariadb://user:password@127.0.0.1:3306/app?charset=utf8mb4
MySQLmysql://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);
One entity manager per request. It keeps the entities it loaded. In a long-running worker, call $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.

AttributePHP typeMain arguments
PrimaryKeyColumn?intAuto-incremented by the database. One per entity.
StringColumn?stringlength: 255, nullable, defaultValue, unique
TextColumn?stringnullable, defaultValue
IntColumn?intnullable, defaultValue, unique
FloatColumn?floatnullable, defaultValue, unique
DecimalColumn?stringprecision: 10, scale: 2, nullable, unique
BoolColumnboolnullable, defaultValue
DateColumn, DateTimeColumn?\DateTimeInterfacenullable
JsonColumn?arraynullable, defaultValue
BinaryColumn?stringnullable
AnyColumnanyStored without conversion.
TimestampColumn, SlugColumn, TokenColumn, UuidColumn, AutoIncrementColumnFilled 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().

Relations are never loaded on their own. Without 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

CommandEffect
paper:migration:diffWrites a migration file with the difference between entities and database.
paper:migration:migrateRuns the migrations not yet applied.
paper:database:syncApplies the difference directly, without a file. --no-execute only prints the SQL. For development.
paper:database:createCreates the database. --if-not-exists.
paper:database:dropDrops the database. Requires --force; development and test only.
paper:show:tablesLists the tables, or the columns of one table with --columns.
paper:query:executeRuns 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.

No cascade. Persisting a user does not persist its posts, and removing it does not remove them. Persist or remove each entity, or let the foreign key rule of the database (onDelete) act.

8. Generated values

These columns are filled by PaperORM when the entity is inserted (and updated, for onUpdated).

AttributeValue
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.
UuidColumnRandom 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:

ValueCondition
'jane@example.com', 42, true=
[1, 2, 3]IN (1, 2, 3)
nullIS 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

MethodReturns
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
  1. A first query selects the ids of the page, with your conditions and your order, including those on relations.
  2. 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);
MethodUse
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();
});
EventWhen
Create\PreCreateEvent, Create\PostCreateEventBefore and after an INSERT.
Update\PreUpdateEvent, Update\PostUpdateEventBefore and after an UPDATE that changed something.
Delete\PreDeleteEvent, Delete\PostDeleteEventBefore 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');
Sequences are locked with a file lock (michel/lock). It protects processes on the same machine. With several servers writing the same sequence, pass a 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%'
OptionDefaultMeaning
dsnrequiredDatabase DSN, see Configuration.
debugfalseRecord the queries, shown in the profiler.
loggerpaper.loggerService id of a PSR-3 logger. paper.logger writes to var/log/paper.log. null disables it.
entity_dir%kernel.project_dir%/src/EntityWhere your entities are.
migrations_dir%kernel.project_dir%/migrationsWhere migration files are written.
migrations_tablemig_versionsTable recording the applied migrations.
proxy_autoloadfalseSet 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).