LAMPDev

~/blog / doctrine-orm-without-symfony.md

Using Doctrine ORM without the Symfony framework

Doctrine is usually met through Symfony, but the ORM is a standalone library. It works just as well in a custom PHP application, a console tool or a legacy codebase that is not built on any framework.

A typical case: a console application already talks to MySQL through Doctrine DBAL and its query builder, and we want real entities and an EntityManager without pulling in symfony/framework-bundle.

Updated in 2026 for Doctrine ORM 3. The original 2020 version of this article generated entities from the database with orm:convert-mapping and orm:generate-entities, and configured mapping through docblock annotations. ORM 3 removed both tools and annotation support, so this version uses PHP 8 attributes and writes the entities by hand.

1. Install the ORM

composer require doctrine/orm symfony/cache

doctrine/orm pulls in doctrine/dbal, so existing DBAL code keeps working. symfony/cache provides the metadata cache that ORMSetup uses.

2. Create the EntityManager

Keep the bootstrap code in one file that returns a ready EntityManager. Both the application and the Doctrine console will use it.

<?php
// config/doctrine.php

use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMSetup;

require_once __DIR__ . '/../vendor/autoload.php';

$config = ORMSetup::createAttributeMetadataConfiguration(
    paths: [__DIR__ . '/../src/Entity'],
    isDevMode: (bool) getenv('APP_DEBUG'),
);

// PHP 8.4+: use native lazy objects for proxies
$config->enableNativeLazyObjects(true);

$connection = DriverManager::getConnection([
    'driver'   => 'pdo_mysql',
    'host'     => getenv('DB_HOST') ?: 'db',
    'dbname'   => getenv('DB_NAME') ?: 'scraper',
    'user'     => getenv('DB_USER') ?: 'scraper',
    'password' => getenv('DB_PASSWORD') ?: '',
    'charset'  => 'utf8mb4',
], $config);

return new EntityManager($connection, $config);

Read credentials from environment variables rather than hard-coding them. The file is safe to commit that way.

enableNativeLazyObjects() makes Doctrine build its lazy-loading proxies with PHP 8.4's built-in lazy objects. Recent releases need it: with Symfony 8 components installed, Doctrine stops with "Symfony LazyGhost is not available" unless this is enabled. On PHP 8.1–8.3, leave the line out and run composer require symfony/var-exporter:^7.0 instead.

3. Enable the Doctrine console

Without Symfony there is no bin/console, so give Doctrine its own entry point:

#!/usr/bin/env php
<?php
// bin/doctrine

use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Doctrine\ORM\Tools\Console\EntityManagerProvider\SingleManagerProvider;

$entityManager = require __DIR__ . '/../config/doctrine.php';

ConsoleRunner::run(new SingleManagerProvider($entityManager));

Make it executable with chmod +x bin/doctrine, then run bin/doctrine list to see the available commands.

4. Map the existing tables

In this example the database already has three tables: product, category and manufacturer. Each product belongs to one category and one manufacturer. With attributes, the mapping sits next to the properties:

<?php
// src/Entity/Category.php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

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

    /** @var Collection<int, Product> */
    #[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category')]
    private Collection $products;

    public function __construct(
        #[ORM\Column(length: 255, unique: true)]
        private string $name,
    ) {
        $this->products = new ArrayCollection();
    }

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

    public function getName(): string
    {
        return $this->name;
    }

    /** @return Collection<int, Product> */
    public function getProducts(): Collection
    {
        return $this->products;
    }
}

Manufacturer looks the same. The owning side of each relation is Product, which holds the foreign keys:

<?php
// src/Entity/Product.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

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

    #[ORM\Column(length: 255)]
    private string $title;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
    private ?string $price = null;

    #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')]
    #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: true)]
    private ?Category $category = null;

    #[ORM\ManyToOne(targetEntity: Manufacturer::class, inversedBy: 'products')]
    #[ORM\JoinColumn(name: 'manufacturer_id', referencedColumnName: 'id', nullable: true)]
    private ?Manufacturer $manufacturer = null;

    public function __construct(string $title)
    {
        $this->title = $title;
    }

    public function setPrice(?string $price): static
    {
        $this->price = $price;

        return $this;
    }

    public function setCategory(?Category $category): static
    {
        $this->category = $category;

        return $this;
    }

    public function setManufacturer(?Manufacturer $manufacturer): static
    {
        $this->manufacturer = $manufacturer;

        return $this;
    }

    // ...getters
}

The category_id and manufacturer_id columns are now represented by the $category and $manufacturer objects. Doctrine reads and writes the foreign keys for you.

Check that the mapping matches the real tables before you rely on it:

bin/doctrine orm:validate-schema
bin/doctrine orm:schema-tool:update --dump-sql

The first command should report "The mapping files are correct". On a legacy database it will usually add that the schema is not in sync. The second command shows why: it only prints the SQL Doctrine thinks is missing, typically foreign key constraints the old tables never had, extra indexes on the foreign key columns, and renamed indexes:

ALTER TABLE product ADD CONSTRAINT FK_D34A04AD12469DE2 FOREIGN KEY (category_id) REFERENCES category (id);
CREATE INDEX IDX_D34A04AD12469DE2 ON product (category_id);
ALTER TABLE category RENAME INDEX uniq_name TO UNIQ_64C19C15E237E06;

Adding the foreign keys is usually worth it, but first check that the data has no orphaned rows. Review that output carefully. Use a migrations tool such as doctrine/migrations for schema changes, never --force on production.

5. Use entities instead of the query builder

Inject the EntityManagerInterface into the class that used to receive the DBAL connection:

use Doctrine\ORM\EntityManagerInterface;

final class ScraperCommand extends Command
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly LoggerInterface $logger,
    ) {
        parent::__construct('app:scrape');
    }
}

Then work with objects. Find a record or create it, and persist the new ones:

/** @var array<string, Category> categories seen in the current batch */
private array $categories = [];

private function category(string $name): Category
{
    if (isset($this->categories[$name])) {
        return $this->categories[$name];
    }

    $category = $this->em->getRepository(Category::class)->findOneBy(['name' => $name]);

    if ($category === null) {
        $category = new Category($name);
        $this->em->persist($category);
    }

    return $this->categories[$name] = $category;
}

private function saveProduct(array $item): void
{
    $product = (new Product($item['title']))
        ->setPrice($item['price'] ?? null)
        ->setCategory($this->category($item['category']))
        ->setManufacturer($this->manufacturer($item['manufacturer']));

    $this->em->persist($product);
}

The small $categories cache matters. A category created earlier in the same batch is not in the database until the next flush(), so findOneBy() cannot see it. Without the cache, the second product of a new category would create a duplicate.

Call $this->em->flush() once per batch, for example every 100 products and once at the end, rather than after every persist(). Each flush() opens a transaction and computes a change set, so flushing per row is the most common reason a Doctrine import is slow. For very long imports, also call $this->em->clear() after each batch to keep memory flat. Empty the $categories cache at the same time: clear() detaches every entity, and Doctrine refuses to link a new product to a detached category.

From here the project has the full ORM: repositories, DQL, lifecycle events, and doctrine/migrations for schema changes, all without a framework.