DEV Community

Jarosław Szutkowski
Jarosław Szutkowski

Posted on

One-to-One in Doctrine: How One Wrong Line of Code Generated 40,000 Extra Queries Per Day

A real-world debugging story - and the hidden mechanics behind Doctrine's 1:1 relations

A few years ago, I was working on a high-traffic Symfony application. Lots of concurrent users, lots of read operations, performance carefully monitored. Everything seemed stable - until our database dashboards started showing something odd.

Every single day, we were generating tens of thousands of redundant SELECTs.
Not tied to any feature we were building.
Not related to reporting.
Not caused by queue workers.

Just 40,000+ pointless queries.
Noise.
Database load with zero business value.

We dug into logs. Then into slow-query reports. Still nothing obvious.

And then the profiler finally revealed the culprit:
a bidirectional One-to-One relation in Doctrine, configured on the wrong owning side.

One annotation. One line of code.
A silent performance killer.

When we flipped the owning side to the entity we actually queried most often, the extra queries disappeared immediately.

This is the part nobody tells you about Doctrine: a One-to-One relation looks trivial, but one wrong assumption can quietly drain your performance budget for months.


🧩 The Hidden Complexity of One-to-One in Doctrine

On paper, a one-to-one relationship seems simple:

  • one country → one capital
  • one profile → one avatar
  • one customer → one address

But Doctrine doesn't treat it like a basic SQL constraint.
In Doctrine, one detail defines the entire behaviour:

👉 Only one side owns the foreign key.

That side is the owning side.
The other (mappedBy) is the inverse side.

And Doctrine's behaviour changes dramatically depending on which side you load.


🏗️ Example Setup

We'll use a simple example: CountryCapitalCity.

CapitalCity (Owning side)

#[ORM\Entity]
class CapitalCity
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(length: 50)]
    private string $name;

    #[ORM\OneToOne(targetEntity: Country::class, inversedBy: 'capitalCity')]
    #[ORM\JoinColumn(nullable: false)]
    private Country $country;

    public function __construct(string $name, Country $country)
    {
        $this->name = $name;
        $this->country = $country;
    }
}
Enter fullscreen mode Exit fullscreen mode

Country (Inverse side)

#[ORM\Entity]
class Country
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(length: 50)]
    private string $name;

    #[ORM\OneToOne(mappedBy: 'country', targetEntity: CapitalCity::class)]
    private CapitalCity $capitalCity;

    public function __construct(string $name, CapitalCity $capitalCity)
    {
        $this->name = $name;
        $this->capitalCity = $capitalCity;
    }
}
Enter fullscreen mode Exit fullscreen mode

💡 BONUS RESOURCE – Free PDF Guide

⚙️ Want the quick-reference version of this post?

Grab my free Doctrine Performance Checklist (PDF)
"10 Steps to a Faster Doctrine-Based App."

You'll get a concise, printable guide covering:

  • how to spot N+1s in seconds,
  • which Doctrine hints actually matter,
  • and how to batch-process millions of rows safely.

🔗 Download it here:
https://doctrine-performance-list.doctrinelab.pl
(no spam - just pure optimization wisdom)


🔍 Why Doctrine Sometimes Issues Extra Queries

Let's walk through the typical real-world scenarios.


1️⃣ Fetching the Owning Side (Cheap)

$capitalCities = $capitalCityRepo->findAll();
Enter fullscreen mode Exit fullscreen mode

Profiler:

SELECT id, name, country_id FROM capital_city;
Enter fullscreen mode Exit fullscreen mode

Doctrine:

  • creates a proxy for Country
  • loads it only when accessed
  • performs no extra queries

2️⃣ Fetching the Inverse Side (Doctrine Must JOIN)

$countries = $countryRepo->findAll();
Enter fullscreen mode Exit fullscreen mode

Profiler:

SELECT ... FROM country
LEFT JOIN capital_city ON capital_city.country_id = country.id
Enter fullscreen mode Exit fullscreen mode

Because the inverse side has no foreign key, Doctrine must inspect the other table.


3️⃣ Using QueryBuilder (The Silent N+1)

This is where the biggest issues occur:

$countries = $countryRepo
    ->createQueryBuilder('c')
    ->getQuery()
    ->getResult();
Enter fullscreen mode Exit fullscreen mode

Profiler:

SELECT * FROM country;
SELECT * FROM capital_city WHERE country_id = 1;
SELECT * FROM capital_city WHERE country_id = 2;
...
Enter fullscreen mode Exit fullscreen mode

Doctrine assumes:

  • If you write custom DQL → you control the joins
  • It won't automatically add them
  • It will lazy-load relations one-by-one
  • It will lazy-load relations even if you don't access them

This can easily multiply into tens of thousands of hidden SELECTs per day.

We could add a FETCH JOIN here, but it would defeat the purpose - in these queries we weren't actually using the related entities we'd be joining.


💡 The Fix: Put the Owning Side Where You Fetch Most

This rule would have saved us weeks of debugging:

👉 Make the entity you load most often the owning side.

Why?

  • the owning side has the FK
  • Doctrine doesn't have to join
  • it won't lazy-load in a loop IF you don't access the relation
  • performance becomes predictable

In our case:

  • We fetched Country far more often than CapitalCity.
  • But the owning side was mistakenly on CapitalCity.
  • Doctrine repeatedly scanned for capital cities when loading countries.
  • After flipping the owning side → query count dropped by 40k+ per day.

No business logic changes.
Just correct ORM mapping.


🧭 Practical Guidance for Developers

✔️ 1. Identify the entity loaded more frequently - make it the owning side.
✔️ 2. Don't trust inverse-side findAll() - it hides JOINs.
✔️ 3. Don't trust QueryBuilder without explicit FETCH JOINs - it creates N+1.
✔️ 4. Avoid bidirectional One-to-One unless really needed.
✔️ 5. Use FETCH JOIN intentionally.
✔️ 6. Treat One-to-One as an advanced feature.


🚀 Ready to Go Beyond Quick Fixes?

If you found this article helpful, you'll love my upcoming course:
Doctrine Efficiency Lab 💻

Learn how to:

  • handle millions of rows without custom SQL,
  • debug ORM performance in real time,
  • stop Doctrine from being "that slow layer" in your stack.

🎁 When you join the waitlist, you instantly receive my
10-Step Doctrine Optimization PDF - a condensed version of everything discussed here.

🔗 Join the waitlist + get the free PDF:
https://doctrine-performance-list.doctrinelab.pl


Top comments (0)