ORM Orientation: Doctrine ORM And Laravel Eloquent
An object-relational mapper, or ORM, connects relational database rows with PHP objects or models. It can reduce repetitive persistence code and express relationships clearly, but it does not remove SQL, schema design, transactions, constraints, or performance work.
Two prominent PHP approaches are Laravel Eloquent and Doctrine ORM. They solve overlapping problems with different models of object state and persistence.
Know The Mapping Problem
Relational databases organize facts into tables, rows, keys, joins, and constraints. Object-oriented code organizes behavior into objects, references, collections, and methods. These shapes are not identical.
An ORM commonly handles:
- field-to-column mapping;
- identifiers and generated keys;
- one-to-one, one-to-many, and many-to-many relationships;
- conversion between database and PHP types;
- query construction;
- object hydration;
- insert, update, and delete generation;
- change tracking or model persistence;
- optimistic locking and transaction integration.
The abstraction remains backed by real SQL. A missing index is still missing. A uniqueness race still needs a database constraint. A lazy relationship can still execute hundreds of queries.
Eloquent Uses An Active Record Style
Laravel describes Eloquent models as representing database tables. A model instance commonly contains row data and provides methods for querying, relationships, and persistence.
<?php
declare(strict_types=1);
final class EloquentQueryDescription
{
public function paidOrders(): string
{
return "Order::query()->where('status', 'paid')->with('customer')->latest()->limit(20)->get()";
}
}
echo (new EloquentQueryDescription())->paidOrders(), PHP_EOL;
In an actual Laravel application, the chain builds a query, eager-loads customers, orders results, and retrieves models. The Order model is both a domain-facing object and a gateway into persistence behavior.
Active Record is productive for CRUD-heavy applications and framework conventions. It can become awkward when business objects need independence from persistence, when one use case spans several storage systems, or when model lifecycle hooks hide important effects.
Official references: Laravel Eloquent and Eloquent relationships.
Doctrine Uses Entities And An EntityManager
Doctrine ORM maps entity classes and coordinates persistence through an EntityManager. New entities are passed to persist(), and flush() synchronizes tracked changes with the database.
<?php
declare(strict_types=1);
final class DoctrineFlowDescription
{
public function createUser(): array
{
return [
'construct User entity',
'entity manager persist',
'entity manager flush',
];
}
}
print_r((new DoctrineFlowDescription())->createUser());
Calling persist() does not necessarily issue an insert immediately. Doctrine's Unit of Work calculates changes and writes them during flush(). This transactional write-behind behavior allows several object changes to be synchronized together, while making the flush boundary important.
Official references: Doctrine working with objects and Doctrine batch processing.
Identity Maps Affect Object Loading
Doctrine maintains an identity map inside one EntityManager: loading one entity class and identifier repeatedly normally returns the same managed object instance. This avoids contradictory in-memory copies and supports change tracking.
The identity map is not a shared application cache. It belongs to the EntityManager lifecycle. In a long-running worker, retaining many managed entities can consume memory and preserve stale state. Batch work commonly flushes and clears at controlled intervals.
Eloquent models do not provide the same Unit of Work identity-map semantics by default. Repeating a query can create separate model instances representing the same row. Do not assume all ORMs manage object identity alike.
Understand When SQL Executes
ORM code can defer or trigger SQL at surprising points:
- calling a terminal query method such as
get(),first(), or iteration; - accessing a lazy relationship;
- flushing a Doctrine EntityManager;
- counting a collection;
- serializing models with relationships;
- running lifecycle hooks;
- deleting with cascades.
Use query logging, framework debugging tools, traces, and integration tests to see actual statements. Reading method names is not enough.
A code review should ask how many rows and queries execute, not only whether the object chain looks concise.
Relationships Can Cause N+1 Queries
Loading a list and lazily accessing one relationship per row creates N+1 behavior:
<?php
declare(strict_types=1);
function possibleQueryCount(int $orders): int
{
return 1 + $orders;
}
echo possibleQueryCount(30), PHP_EOL;
// Prints:
// 31
Eloquent can eager-load relationships with methods such as with(). Doctrine can use fetch joins or explicit queries. The correct query shape depends on relationship cardinality and result size.
Joining several to-many collections can multiply rows dramatically. Sometimes two or three bounded queries are better than one enormous join: load parents, load each required child set with WHERE IN, then assemble the result.
Assert query counts for important list pages so a template or serializer change does not silently reintroduce N+1 behavior.
Mapping Relationships Requires Ownership Decisions
A relationship has database ownership through its foreign key and ORM ownership through mapping configuration. Bidirectional object references must be kept consistent in PHP when the ORM expects both sides.
Convenience methods should preserve the object graph:
<?php
declare(strict_types=1);
final class Order
{
/** @var list<OrderLine> */
private array $lines = [];
public function addLine(OrderLine $line): void
{
$this->lines[] = $line;
$line->attachTo($this);
}
}
Do not expose mutable collections that let callers create half-updated relationships. Database foreign keys remain necessary even when mapping metadata declares the association.
Cascades should be narrow. Cascade persist or remove can be useful for objects whose lifecycle truly belongs to the aggregate, but dangerous when a related record is shared or independently owned.
Entities And Models Need Business Boundaries
An ORM model can become a bag of public fields, database helpers, validation, email sending, authorization, and presentation formatting. That convenience produces hidden coupling.
Keep business rules explicit. Eloquent projects can use model methods, value objects, actions, services, and dedicated query classes. Doctrine entities can protect invariants through constructors and methods while repositories and application services coordinate persistence.
Do not force every table to have a rich domain entity. Join tables, logs, reporting projections, and simple lookup records may need different representations.
Repositories Serve Queries, Not Generic CRUD Ceremony
Doctrine provides entity repositories, and Laravel applications often create query or repository classes where useful. A repository should express application needs:
ordersAwaitingDispatch()
subscriptionForRenewal()
save(Order $order)
A generic interface with findAll, create, update, and delete for every entity can merely hide the ORM while removing useful features. Introduce a repository boundary when it clarifies domain language, testing, storage replacement, or complex query ownership.
The dedicated Repository Pattern lesson distinguishes repositories from DAO, gateways, Active Record, and query objects.
Transactions Still Belong To Use Cases
Saving one model may use a small implicit transaction, but a use case that changes several aggregates, inventory, and an outbox needs an explicit boundary.
Do not let every repository or model commit independently. The application service should own the transaction around all required writes. In Doctrine, flush() and transaction demarcation need deliberate placement. In Laravel, use the database transaction facilities around the complete use case.
External calls remain outside database rollback. Persist durable outbox work instead of sending a message from an ORM lifecycle callback before commit.
Lifecycle Hooks Can Hide Behavior
ORM events and model observers can maintain timestamps, normalize simple fields, or integrate framework behavior. They can also hide network calls, recursive writes, and unexpected queries.
Avoid critical business workflows that only happen because an entity was flushed or a model happened to save. Bulk updates may bypass object hooks. Imports or maintenance scripts may follow another path.
Keep important use-case behavior visible in application services. If hooks enforce an invariant, test every persistence path that could bypass them and retain database constraints where possible.
Mass Assignment Needs Protection
Hydrating or updating a model directly from request arrays can let users change fields they should not control, such as roles, ownership IDs, prices, or status.
Map accepted input explicitly:
<?php
declare(strict_types=1);
function acceptedProfileFields(array $input): array
{
return [
'display_name' => $input['display_name'] ?? null,
'timezone' => $input['timezone'] ?? null,
];
}
Framework allowlists help, but authorization and validation remain required. Never treat ORM convenience as an input-security boundary.
Bulk Work Needs Different Techniques
Hydrating hundreds of thousands of objects can consume excessive memory and change-tracking time. For backfills, reports, exports, and mass updates, consider:
- set-based SQL updates;
- a query builder or DBAL;
- scalar or array hydration;
- streaming iteration;
- bounded batches;
- periodic Doctrine
flush()andclear(); - Eloquent chunking or cursor approaches appropriate to the use case.
Bulk DQL or SQL may bypass entity lifecycle behavior and leave already loaded objects stale. Define which invariants and events must run, and refresh or clear managed state where required.
Use a stable keyset for batches. High offsets become slow and rows can move between pages during concurrent updates.
Schema Migrations Remain Separate
Mapping metadata describes how objects correspond to a schema. Production schema changes still need reviewed migrations. Automatically synchronizing a schema from mappings can be convenient in disposable development, but it is not a safe deployment plan for valuable data.
Review generated migrations for destructive changes, indexes, defaults, locks, data backfills, and deployment compatibility. Application code and schema may need a staged expand-and-contract rollout.
Know When To Use Query Builders Or SQL
An ORM is not mandatory for every query. Prefer a dedicated query builder, DBAL, or SQL for:
- reports and aggregates;
- database-specific features;
- bulk updates and deletes;
- large exports;
- window functions and complex CTEs;
- performance-critical projections;
- queries that return DTOs rather than entities.
Keep parameters bound and tests focused on the real engine. Using SQL inside an ORM project is not failure; forcing a relational report through a complex object graph often is.
Test ORM Behavior At Several Levels
Unit-test entity or model business methods without a database where possible. Integration-test mappings, queries, constraints, transactions, and cascade behavior against the real engine. Test request behavior for authorization and validation.
For query-sensitive paths, assert:
- returned records and ordering;
- query count;
- absence of lazy loads during serialization;
- pagination boundaries;
- transaction rollback;
- optimistic-lock conflicts;
- behavior after bulk updates;
- memory use in representative batches.
Mocks cannot prove mapping or generated SQL correctness. Use an actual database for persistence behavior.
Select An ORM Deliberately
Eloquent fits naturally in Laravel and favors convention, fluent model queries, and Active Record productivity. Doctrine supports a Data Mapper-oriented entity model, explicit mapping, Unit of Work, identity map, and broad integration beyond one full-stack framework.
The choice also involves team knowledge, framework, legacy schema, domain complexity, query profile, operational tooling, and migration cost. Do not rewrite a functioning persistence layer merely to match a pattern preference.
Small scripts and focused services may need only PDO or DBAL. An ORM earns its place when mapping and relationship productivity exceed its abstraction and runtime costs.
Review Checklist
When reviewing ORM code, ask:
- What SQL executes and at which method call?
- How many rows and queries can this path produce?
- Are relationships eager, lazy, or explicitly loaded?
- Which object or mapping side owns each association?
- Are database constraints still present?
- Who owns the transaction and flush boundary?
- Can lifecycle hooks hide queries or external effects?
- Is request data mapped through an allowlist?
- Will bulk work bypass hooks or retain too many objects?
- Would a query builder, DTO projection, or SQL be clearer?
- Are mappings and query behavior integration-tested?
What You Should Be Able To Do
After this lesson, you should be able to distinguish Eloquent's Active Record style from Doctrine's EntityManager, Unit of Work, and identity map. You should know when SQL executes, how relationships create N+1 queries, and why eager loading is not automatically one perfect query.
You should also be able to place transaction ownership, avoid hidden lifecycle behavior, choose a suitable bulk-processing technique, protect against mass assignment, and decide when direct SQL or a query object is better than entity hydration.
Continue with Persistence Patterns: Active Record, Data Mapper, And Unit Of Work for a framework-neutral comparison of these boundaries.
Practice
Practice: Review ORM Query Risk
Write a short PHP example that explains what an ORM might hide.
Requirements
- Show an Eloquent-style query string.
- Show a Doctrine-style persist/flush flow.
- Calculate how many queries an N+1 relationship load could create.
- Name one case where raw SQL or a query builder may be better than an ORM.
- Explain why indexes still matter.
Show solution
This solution uses strings because the goal is to recognise patterns, not install a framework.
<?php
declare(strict_types=1);
function nPlusOneQueryCount(int $parentRows): int
{
return 1 + $parentRows;
}
$eloquent = "Order::query()->where('status', 'paid')->with('user')->latest()->limit(20)->get()";
$doctrine = ['new User()', 'entityManager->persist($user)', 'entityManager->flush()'];
echo $eloquent . PHP_EOL;
echo implode(' -> ', $doctrine) . PHP_EOL;
echo 'without eager loading, 20 orders may become ' . nPlusOneQueryCount(20) . ' queries' . PHP_EOL;
// Prints:
// Order::query()->where('status', 'paid')->with('user')->latest()->limit(20)->get()
// new User() -> entityManager->persist($user) -> entityManager->flush()
// without eager loading, 20 orders may become 21 queries
Raw SQL or a query builder may be better for reports, large exports, bulk updates, or database-specific features. Indexes still matter because the ORM ultimately runs SQL against real tables.
Choose A Persistence Tool
Explain transaction, memory, lifecycle-hook, and testing implications for each choice.
Show solution
Load an entity or model to edit one behavior-rich aggregate and test its mapping and transaction. Use a query builder or SQL DTO projection for the revenue report and paginated list so only required columns are returned. Use set-based SQL or carefully batched DBAL for the million-row update.
Bulk SQL avoids object hydration but may bypass hooks and leave managed objects stale. Run it in a deliberate transaction strategy, clear or refresh ORM state, test constraints and row selection, and do not assume per-object events occurred.