databases storage and caching

ORM Orientation: Doctrine ORM And Laravel Eloquent

An ORM, or object-relational mapper, maps database rows to PHP objects or models. It can make application code more expressive, but it does not remove the need to understand SQL, schema design, transactions, and query performance.

The two ORM names PHP developers most commonly meet are Doctrine ORM and Laravel Eloquent.

What An ORM Does

An ORM usually handles:

  • Mapping tables to classes or models.
  • Loading rows as objects.
  • Saving object changes back to the database.
  • Defining relationships such as user has many orders.
  • Building SQL through methods instead of raw strings.
  • Managing transactions and unit-of-work behaviour in some frameworks.

That convenience has a cost: hidden queries are easier to create.

Eloquent Shape

Laravel Eloquent uses active record style models. The model usually knows how to query and save itself.

PHP example
<?php

declare(strict_types=1);

final class EloquentStyleExample
{
    public function recentPaidOrders(): string
    {
        return "Order::query()->where('status', 'paid')->latest()->limit(20)->get()";
    }
}

echo (new EloquentStyleExample())->recentPaidOrders() . PHP_EOL;

// Prints:
// Order::query()->where('status', 'paid')->latest()->limit(20)->get()

In real Laravel code, that chain builds and executes SQL. You still need indexes for the status and ordering pattern.

Doctrine ORM Shape

Doctrine ORM uses entities and an entity manager. It has a unit of work that tracks changes and flushes them to the database.

PHP example
<?php

declare(strict_types=1);

final class DoctrineStyleExample
{
    public function createUserFlow(): array
    {
        return ['new User()', 'entityManager->persist($user)', 'entityManager->flush()'];
    }
}

print_r((new DoctrineStyleExample())->createUserFlow());

// Prints:
// entityManager->flush()

Doctrine can be very powerful, but developers must understand when SQL is actually executed.

Relationships And N+1 Queries

ORM relationships are convenient, but they can create N+1 query problems.

PHP example
<?php

declare(strict_types=1);

function possibleQueriesForOrdersAndUsers(int $orders): int
{
    return 1 + $orders;
}

echo possibleQueriesForOrdersAndUsers(30) . PHP_EOL;

// Prints:
// 31

If code loads 30 orders and then lazily loads the user for each order, it may run 31 queries. Use eager loading, joins, or explicit query design when listing related data.

When To Use Raw SQL Or Query Builders

Even in ORM projects, raw SQL or a query builder may be better for:

  • Reports and aggregates.
  • Large exports.
  • Batch updates.
  • Performance-critical list pages.
  • Queries using database-specific features.

Using an ORM well includes knowing when not to force everything through it.

What To Check

Before moving on, make sure you can:

  • Explain what an ORM maps.
  • Distinguish Doctrine ORM from Doctrine DBAL.
  • Recognise Eloquent's active record style.
  • Recognise Doctrine's entity manager and flush style.
  • Spot N+1 query risks.
  • Explain why ORM code still needs schema and SQL knowledge.

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 example
<?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.