Doctrine ORM

Last updated: 2026-11-01

Canvas Doctrine brings Doctrine ORM into Canvas. It wires a single Doctrine DBAL connection and attribute-mapped entities into Canvas's dependency injection container, so EntityManagerInterface and Connection are available anywhere Canvas resolves a constructor or controller action.

explanation

Core Concepts

  • Single connection — this package manages exactly one DBAL connection, read from config/doctrine.php. Multiple named connections are not supported.
  • Lazy, memoized EntityManager — the EntityManager is built on first resolution, not eagerly at boot, and reused for every subsequent injection of EntityManagerInterface, EntityManager, or Connection within the same provider instance.
  • Attribute-mapped entities — entities are mapped with Doctrine's native PHP attributes (#[ORM\Entity], #[ORM\Table], #[ORM\Column], etc.), scanned from the directories listed in entity_paths.
  • Entity parameter binding — a controller action parameter typed as a mapped entity class under entity_namespace is resolved automatically from a route value. See Entity Parameter Binding below.
Using ObjectQuel too? Don't let entity_namespace (or the entity path) overlap between canvas-doctrine and canvas-objectquel. Both packages' route-entity binders match a controller parameter by namespace alone, so a class belonging to the other ORM can end up bound through the wrong EntityManager.

Installation

Install via Composer:

composer require quellabs/canvas-doctrine

Then publish the configuration file:

sculpt doctrine:init

Edit config/doctrine.php with your database credentials and entity locations.

Configuration

All options with their defaults:

// config/doctrine.php
return [
    'entity_paths'     => [__DIR__ . '/../src/Entities'],
    'entity_namespace' => 'App\\Entities',
    'development_mode' => false,
    'connection'        => [
        'driver'   => 'pdo_mysql',
        'host'     => 'localhost',
        'port'     => 3306,
        'dbname'   => '',
        'user'     => '',
        'password' => '',
        'charset'  => 'utf8mb4',
    ],
];
  • entity_paths — directories scanned for attribute-mapped entity classes. Must be a non-empty list of strings.
  • entity_namespace — the namespace prefix used for controller route binding (see below). Unrelated to entity_paths; keep the two consistent with where your entity classes actually live.
  • development_mode — when true, metadata/query/result caching uses an in-memory array that doesn't persist across requests, so entity changes are picked up immediately. Keep it false in production, where Doctrine uses a persisted cache instead.
  • connection — a single DBAL connection array. Any key you omit falls back to the default shown above. A list of connection arrays, or a connections key, is rejected — only one connection is supported.

Quick Start

Two ways to work with your database, depending on how much of Doctrine you want: entities through the EntityManager, or DBAL's query builder directly.

Doctrine Entities

Type-hint EntityManagerInterface in any constructor or controller action, and Canvas resolves it to the single, lazily-built EntityManager — built from config/doctrine.php on first use, no manual wiring:

use Doctrine\ORM\EntityManagerInterface;

class BlogController {
    public function __construct(
        private EntityManagerInterface $em
    ) {}

    public function show(int $id): Response {
        $post = $this->em->find(Post::class, $id);

        return $this->render('blog/show.tpl', compact('post'));
    }
}
Note: Post here is a plain Doctrine attribute-mapped entity, loaded from a directory listed in entity_paths. Mapping entities, querying through repositories, and managing the unit of work with persist()/flush() is Doctrine's own API, unchanged by Canvas.

DBAL Connection via DI

For code that prefers DBAL's query builder without entity classes, type-hint Connection instead — it resolves to the same underlying connection the EntityManager uses:

use Doctrine\DBAL\Connection;

class ReportController {
    public function __construct(
        private Connection $db
    ) {}

    public function index(): Response {
        $rows = $this->db->createQueryBuilder()
            ->select('id', 'title')
            ->from('posts')
            ->fetchAllAssociative();

        return $this->json(['posts' => $rows]);
    }
}

Entity Parameter Binding

A controller action parameter typed as a mapped entity class under entity_namespace is resolved automatically from a route value, without calling EntityManager::find() yourself.

Implicit binding by parameter name

By default, the route value with the same name as the controller parameter is used as the primary key:

// Route: /posts/{post}
public function show(Post $post) {
    // $post is the Post whose primary key matches the route's {post} value
}

@ResolveEntity for a differently-named route value

When the route value's name differs from the controller parameter's name, add @ResolveEntity to map them:

/**
 * @Route('/posts/{postId}')
 * @ResolveEntity(parameter="post", routeParam="postId")
 */
public function show(Post $post) {
    // $post is resolved from the route's {postId} value
}

Not-found behavior

  • Required parameter (Post $post): if the lookup finds no row, a RequiredParameterNotFoundException is thrown, which produces an HTTP 404 response.
  • Nullable parameter (?Post $post): if the lookup finds no row, the controller receives null and must handle it.

Binding errors vs. a missing row

A missing row is not the only possible outcome. If the route has no value at all for the key the parameter is bound to, or the value's shape is not supported (this provider only binds scalar primary keys — composite/array keys are not supported), that is a binding or configuration mistake, not "no row found", and raises InvalidArgumentException rather than silently injecting null or a default.

Sculpt CLI Commands

CommandDescription
sculpt doctrine:initPublish config/doctrine.php to the project root (skips if it already exists)