crystoline/xpsr-orm-doctrine

Doctrine ORM for XPSR applications. Entities are plain PHP classes mapped with attributes; the EntityManager runs on XPSR's database connection from crystoline/xpsr-database, so ORM work, plain DBAL queries, and test rollbacks share one transaction. Schema changes go through XPSR migrations, generated from the mapping with make:migration --diff.

composer require crystoline/xpsr-orm-doctrine

Add both providers to app.registry in config/app.php, database first:

Crystoline\Xpsr\Database\DatabaseServiceRegistry::class,
Crystoline\Xpsr\Orm\Doctrine\DoctrineOrmServiceRegistry::class,

Entities

Put entity classes in app/Entity/ (App\Entity\…):

namespace App\Entity;

use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'tasks')]
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    public ?int $id = null;

    #[ORM\Column]
    public DateTimeImmutable $createdAt;

    public function __construct(
        #[ORM\Column(length: 200)]
        public string $title,
    ) {
        $this->createdAt = new DateTimeImmutable();
    }
}

Columns are named in snake case: createdAt is stored as created_at. See Doctrine's attribute reference for relations, indexes, and types.

Create the table from the mapping, review the generated SQL, and run it:

php xpsr make:migration create_tasks_table --diff
php xpsr migrate
php xpsr orm:validate-schema

Then inject Doctrine\ORM\EntityManagerInterface:

use Doctrine\ORM\EntityManagerInterface;

public function __construct(private readonly EntityManagerInterface $entities) {}

$task = new Task('Write the docs');
$this->entities->persist($task);
$this->entities->flush();

$open = $this->entities->getRepository(Task::class)->findBy([], ['createdAt' => 'DESC'], 20);

Schema changes

After changing entities, php xpsr make:migration <name> --diff compares the database with the mapping and writes a migration with the SQL for the difference in up() and its reverse in down(). It is an ordinary XPSR migration file: read it, edit it if needed, and commit it.

  • Run pending migrations first; --diff refuses while migrations are pending, because the database would not reflect them.
  • Only tables the entities describe are compared. The migrations table and tables you manage by hand are never dropped, so removing an entity does not drop its table: write that migration yourself.
  • The SQL is for the database type of the connection. Generate migrations against the same type of database you deploy, for example MySQL in development when production uses MySQL.
  • When nothing differs, no file is written.

php xpsr orm:validate-schema checks the mapping and whether the database matches it, comparing the same tables as --diff. It exits with 1 for mapping errors, 2 when the database differs, and 3 for both. --skip-mapping and --skip-sync run one check.

Doctrine's orm:schema-tool:* commands are not offered: they change the database directly, outside migrations.

Configuration

config/orm.php is optional:

return [
    'connection' => null,             // a name from database.connections; null for the default
    'entities' => ['app/Entity'],     // directories of entity classes, relative to the project root
    'metadata_cache' => 'auto',       // auto, array, or the service ID of a PSR-6 pool
    'naming' => 'underscore',         // underscore (created_at) or default (createdAt)
];
Setting Meaning
entities Without this setting, app/Entity is used once it exists. A directory listed here must exist.
metadata_cache auto caches the mapping in the application cache (crystoline/xpsr-cache) when it is installed and APP_DEBUG is off, and in memory otherwise, so edits to entities apply at once during development. array always uses memory. Run php xpsr cache:clear or orm:clear-cache:metadata after deploying changed entities.
naming How property names become column names when a column has no explicit name.

Unknown or malformed settings fail when the entity manager is first used, with the setting's name.

Proxy classes are not generated: Doctrine uses PHP 8.4's native lazy objects, so there are no proxy files to build or deploy.

Commands

Command What it does
orm:info Lists the mapped entity classes.
orm:mapping:describe <entity> Shows the mapping of one entity.
orm:validate-schema Checks the mapping and the database, as described above.
orm:run-dql <dql> Runs a DQL query and prints the result.
orm:clear-cache:metadata, :query, :result Clear Doctrine's caches.

Worker servers

With PHP-FPM or Apache each request starts with a fresh entity manager. A process that serves many requests must clear it between them; add the middleware first in app.middleware:

Crystoline\Xpsr\Orm\Doctrine\Http\ClearEntityManagerMiddleware::class,

Users

Entities can be the users of config/auth.php:

'users' => ['driver' => 'doctrine', 'entity' => App\Entity\User::class, 'login' => 'email'],

The entity implements Crystoline\Xpsr\Auth\UserInterface, and Crystoline\Xpsr\Orm\Doctrine\Auth\PasswordUpgradableInterface to let sign-in replace outdated password hashes (the provider flushes). See the XPSR repository's docs/AUTH.md.

Testing

Crystoline\Xpsr\Database\Testing\DatabaseTransactions from the database package covers the ORM too: the entity manager writes through the same connection, so its changes are rolled back after each test. Each test also gets a new application, and with it a new entity manager.

This adapter passes Crystoline\Xpsr\Database\Testing\OrmAdapterContractTestCase: it uses XPSR's connection and transaction, reads data written by plain queries, and drops its state between requests. Its tests, including generating, running, and rolling back a --diff migration, run against SQLite, MySQL 8.4, MariaDB 11, and PostgreSQL 17.

Services

Service Provides
Doctrine\ORM\EntityManagerInterface The entity manager (also Doctrine\ORM\EntityManager). Shared.
Crystoline\Xpsr\Database\Schema\SchemaProviderInterface The schema the entities need, used by make:migration --diff.
Crystoline\Xpsr\Orm\Doctrine\Http\ClearEntityManagerMiddleware Clears the entity manager after each request.

Requires PHP 8.4+.