~/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-mappingandorm: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.