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)
    'listeners' => [],                // event subscriber services the entity manager gets
];
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.
listeners Service IDs, or class names, of Doctrine\Common\EventSubscriber implementations; each is added to the entity manager's event manager when it is built. Other packages add theirs through the Crystoline\Xpsr\Orm\Doctrine\DoctrineListeners service in their provider's boot(), as the audit log does.

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+.