Doctrine and the ORM

What an object-relational mapper does, what it costs, and why Doctrine is a separate library that Symfony only wires up.

Syntaxcomposer require symfony/orm-pack

An ORM — object-relational mapper — maps PHP classes onto database tables. The class is the table. An object is a row. A typed property is a column. You write PHP, and the library writes the SQL.

Doctrine is the ORM used here. It is not part of Symfony. It is a separate project with its own releases and its own documentation, and it runs without a framework at all. Symfony reaches it through DoctrineBundle, which registers Doctrine's objects as services — a service being any object the framework builds for you and hands to your code. One command installs all of it: composer require symfony/orm-pack.

Two layers

DBAL, the Database Abstraction Layer, is the lower one. It opens the connection, sends SQL, binds parameters, and converts values between PHP and database types. The ORM sits on top and adds three things: the mapping, hydration (filling a PHP object's properties from a row), and the unit of work (an in-memory record of what you changed, sent to the database in one batch).

Entities are plain objects

A mapped class is called an entity. It has no save() and no find(), and it knows nothing about the database. A separate object, the entity manager, reads and writes it. That split is the data mapper pattern.

What it costs

You stop seeing the SQL, so a loop over objects can quietly issue one query per row. Loaded objects stay in memory until you clear them. And Doctrine's vocabulary has to be learnt before its error messages mean anything. For rows you only want to read and total up, DBAL on its own is often the better tool.

Example

Example · php
<?php
// src/Entity/Article.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

// #[ORM\Entity] is what turns an ordinary class into a mapped entity.
#[ORM\Entity]
#[ORM\Table(name: 'article')]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 160)]
    private string $title = '';

    #[ORM\Column(type: Types::TEXT)]
    private string $body = '';

    public function getId(): ?int
    {
        return $this->id;
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
    }

    // getTitle(), getBody() and setBody() follow the same shape.
}

// The table this mapping describes, on MySQL:
// CREATE TABLE article (
//     id INT AUTO_INCREMENT NOT NULL,
//     title VARCHAR(160) NOT NULL,
//     body LONGTEXT NOT NULL,
//     PRIMARY KEY(id)
// );

When to use it

  • Decide whether a reporting query should go through entities or straight through DBAL.
  • Read an unfamiliar Symfony project and tell which classes are mapped to tables.
  • Explain in review why an entity class has no save() method.
  • Pin the Doctrine ORM and DBAL versions a project depends on before an upgrade.

More examples

The two layers, in configuration

The split is visible in the file the DoctrineBundle recipe writes. The dbal key knows only how to reach the database. The orm key knows where mapped classes live and how to read them: type: attribute means the mapping comes from #[ORM\...] attributes on the class rather than from XML files. The underscore naming strategy is why a createdAt property lands in a created_at column.

Example · yaml
# config/packages/doctrine.yaml
doctrine:
    dbal:
        url: '%env(resolve:DATABASE_URL)%'
    orm:
        auto_mapping: true
        naming_strategy: doctrine.orm.naming_strategy.underscore
        mappings:
            App:
                type: attribute
                is_bundle: false
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'
                alias: App

Skipping the ORM: DBAL on its own

This is the lower layer by itself: no entity class, no objects, no change tracking, just rows. fetchFirstColumn(string $query, array $params = [], array $types = []) returns the first column of every row as a flat array. Reach for it when you want a count, a report or a one-off bulk statement and would throw the objects away anyway.

Example · php
<?php
// src/Service/ArticleStats.php

declare(strict_types=1);

namespace App\Service;

use Doctrine\DBAL\Connection;

final class ArticleStats
{
    // The DBAL connection is a service; inject it, do not fetch it.
    public function __construct(private readonly Connection $connection)
    {
    }

    /** @return list<string> */
    public function titlesLongerThan(int $length): array
    {
        // The value is bound as a parameter, never concatenated into the SQL.
        return $this->connection->fetchFirstColumn(
            'SELECT title FROM article WHERE LENGTH(title) > ? ORDER BY title',
            [$length],
        );
    }
}

// Output: ['Connecting to a Database', 'The Entity Manager']

The same row through the ORM

The same single row read through the upper layer. You get an Article with its properties set instead of an array of columns, and from here Doctrine watches the object for changes. find() returns null when there is no such row, which is why the nullsafe operator appears; the ternary underneath it does exactly the same thing.

Example · php
<?php
// src/Service/ArticleReader.php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;

final class ArticleReader
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function titleOf(int $id): ?string
    {
        // find() runs the SELECT and fills an Article from the row.
        // That filling step is hydration.
        $article = $this->entityManager->find(Article::class, $id);

        // Shorthand for: $article === null ? null : $article->getTitle()
        return $article?->getTitle();
    }
}

// Output: Connecting to a Database

Discussion

  • Be the first to comment on this lesson.