doctrine/orm
Doctrine ORM is an object-relational mapper for PHP 8.1+ that provides transparent persistence for PHP objects on top of the DBAL, with DQL, an object-oriented SQL dialect inspired by Hibernate's HQL.
Doctrine ORM is not Laravel's default persistence layer — Eloquent is. So "getting started" means deciding where Doctrine earns its place in a Laravel codebase, then wiring it in without fighting the framework.
Minimal install path
composer require doctrine/orm doctrine/dbal
# Laravel bridge (service provider, facades, config, Artisan commands)
composer require laravel-doctrine/orm
# If you want Doctrine-owned schema
composer require doctrine/migrations laravel-doctrine/migrations
php artisan vendor:publish --tag="doctrine"
Then in config/doctrine.php:
managers.default.connection at a Laravel DB connection (mysql, pgsql, etc.) — the bridge reuses Laravel's database.php credentials, so you don't duplicate DSNs.managers.default.paths to your entity directories (e.g. app/Domain/**/Entity).proxy_dir (storage/framework/doctrine/proxies or similar) is writable and gitignored.#[ORM\Entity]), not annotations — annotations were removed in 3.x and the ecosystem is attribute-only now.Where to look first
laravel-doctrine/orm README for the Artisan commands (doctrine:schema:validate, doctrine:query:dql, etc.).First use case
Pick a read-heavy, well-modelled slice — a reporting query, an import pipeline, or a domain aggregate with real invariants — and leave the rest on Eloquent. A good first win: replace a sprawling Eloquent query with a typed repository method backed by DQL that returns entities or scalar arrays, so you can compare hydration behaviour side by side without committing the whole app.
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
#[ORM\Table(name: 'invoices')]
class Invoice
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $number;
#[ORM\ManyToOne(targetEntity: Customer::class, inversedBy: 'invoices')]
private Customer $customer;
}
$invoice = new Invoice($number, $customer);
$em->persist($invoice);
$em->flush(); // no save(); the flush is the commit
Unit of Work, not ActiveRecord. The core discipline: mutate objects, persist() new ones, flush() once at the end of the unit of work. Doctrine diffs the object graph and emits the minimal SQL. Resist per-row flushes.
$em->wrapInTransaction(function (EntityManagerInterface $em) use ($order) {
$order->markPaid();
$em->persist(new Payment($order, $amount));
// single flush at commit
});
wrapInTransaction() is the idiomatic replacement for manually managing beginTransaction/commit/rollback/close.
Repositories as the query boundary. Register a repository class on the entity and let controllers/services depend on it — not on the EntityManager.
class InvoiceRepository extends EntityRepository
{
/** @return Invoice[] */
public function overdueSince(DateTimeImmutable $cutoff): array
{
return $this->createQueryBuilder('i')
->where('i.dueDate < :cutoff')
->andWhere('i.paidAt IS NULL')
->setParameter('cutoff', $cutoff)
->orderBy('i.dueDate', 'ASC')
->getQuery()
->getResult();
}
}
Prefer the Criteria API for composable filters ($repo->matching($criteria)) so callers can add andWhere/orderBy without duplicating repository methods.
DQL for joins, QueryBuilder for conditionals. DQL is expressive and portable across drivers; the QueryBuilder is what you want when the WHERE clause is assembled dynamically.
$dql = 'SELECT c, COUNT(i.id) AS invoiceCount
FROM App\Domain\Customer\Entity\Customer c
LEFT JOIN c.invoices i
GROUP BY c.id
HAVING COUNT(i.id) > :threshold';
$rows = $em->createQuery($dql)
->setParameter('threshold', 5)
->getArrayResult(); // scalar/array hydration for reports
Hydration modes are the performance dial. getResult() (objects) for domain work; getArrayResult() / HYDRATE_SCALAR for grids, exports and API payloads where you don't need managed entities. Partial hydration (SELECT PARTIAL i.{id,number} ...) buys you a little, but beware uninitialized fields.
Pagination. Doctrine\ORM\Tools\Pagination\Paginator handles the COUNT query and fetch-join correctness for you; combine with Laravel's LengthAwarePaginator if you want the familiar response shape.
Streaming large result sets. Use toIterable() with batched clear() rather than getResult() for imports.
foreach ($em->createQuery('SELECT p FROM Product p')->toIterable() as $i => $product) {
$product->reindex();
if ($i % 500 === 0) {
$em->flush();
$em->clear(); // detaches everything — see gotchas
}
}
Value objects via embeddables and custom types. #[ORM\Embedded] for Money, Address; a DBAL\Types\Type subclass for Uuid/Email/enum-like columns. This is where Doctrine beats Eloquent decisively for domain modelling.
Timestamps and auditing via lifecycle events. prePersist/preUpdate for createdAt/updatedAt, or an event subscriber for a full audit trail. #[ORM\HasLifecycleCallbacks] plus methods is fine for small concerns; subscribers scale better.
Inheritance when the model demands it. Single-table inheritance (#[ORM\InheritanceType('SINGLE_TABLE')] + discriminator) for simple polymorphic taxonomies; joined inheritance only when column sparsity justifies the join cost.
Coexistence with Eloquent. Keep them on separate tables. Eloquent remains fine for session/auth/breeze scaffolding; Doctrine owns the domain aggregate. Do not write to the same table through both — the identity maps and caches will disagree silently.
Schema ownership. Either Doctrine Migrations owns the schema (and you drop Laravel migrations for those tables) or Laravel does (and you disable doctrine:schema:update outside local). Mixing both produces diff noise and lost migrations; pick one owner per table and document it.
Testing. SQLite :memory: for fast unit tests, transactions wrapped around each test, and $em->clear() in tearDown(). For Postgres-specific DQL, run a separate integration suite against the real driver — behavioural differences will bite you otherwise.
Laravel integration tips. Bind EntityManagerInterface in a service provider, resolve repositories through a small registry or getRepository(), and keep controllers free of flush(). Add Artisan shortcuts for doctrine:schema:validate in CI and doctrine:query:dql for ad-hoc debugging.
save(). Forgetting flush() is the #1 beginner error; so is calling it inside a loop. One flush per unit of work.$em->find(Invoice::class, 1) twice returns the same instance. If another process changed the row, you'll see stale data until $em->refresh($entity) or $em->clear(). Long-running workers and queue jobs need an explicit clear() (or a fresh manager) between jobs.flush() may close the EntityManager; every subsequent operation throws. With the ManagerRegistry, call resetManager() and rebuild — don't try to keep using the dead one.$invoice->getCustomer()->getName() inside a loop fires a query each time. Fix with a JOIN ... ADD SELECT fetch join in DQL, or a repository method that eager-fetches. Avoid setting fetch: 'EAGER' globally — it just moves the N+1.$customer from getReference() is an uninitialised proxy; instanceof works, get_class() does not return your class. Serialising entities directly (queues, JSON responses) triggers proxy initialisation and can dump the whole graph — use DTOs or HYDRATE_ARRAY.clear() detaches everything. After clear(), previously loaded entities are unmanaged; mutating and flushing them silently does nothing. Re-fetch or re-merge(). This is the classic bug in batch-import loops that hold a reference to a "parent" entity across clear() calls.persist does not cascade by default. cascade: ['persist', 'remove'] on the owning side, orphanRemoval: true when removing a child should delete it. Getting this backwards is how you get FK violations or orphan rows.ApcuCache, RedisCache, file cache) will serve stale mappings after you edit attributes. Use ArrayAdapter (or null) locally; warm the cache in deploy. The same applies to the query cache.SELECT *, every root needs an alias, LIMIT isn't DQL (use setMaxResults/setFirstResult), HAVING requires the aggregate in SELECT in some drivers, and ORDER BY on a non-selected field needs it in the select list for some hydration modes.Doctrine\DBAL\Types and dates. Use datetime_immutable / date_immutable with DateTimeImmutable; mutable date types are a footgun in entities with setters.schema_filter / a custom schema manager rather than accepting the diff blindly. Never run doctrine:schema:update --force in production.doctrine.dbal.logging), watch the query log, and use doctrine:query:dql to iterate on DQL without touching code. $query->getSQL() shows the generated SQL.postLoad, preFlush, onClear), custom hydrators, query hints (->setHint(Query::HINT_REFRESH, true)), second-level cache for reference data, and NativeQuery + ResultSetMapping when you must drop to SQL but want entity results.laravel-doctrine/orm bridge's supported ORM range constrains you — check compatibility before upgrading either side, and read the UPGRADE guide before jumping minors.getReference() vs find(). Use getReference() when you only need the FK for an association (avoids a SELECT); use find() when you need the data. Passing a full entity you already have is cheaper than either.How can I help you explore Laravel packages today?