How I Optimized Lazy Loading in Symfony Doctrine to Improve Performance

Marco Bruijns - MTC Creatives
4 min read6 days ago

--

While working on a Symfony project, I faced an issue in which a OneToOne relationship in Doctrine was explicitly configured to behave lazily, but it was eagerly loading related entities. This, of course, had needless performance overhead, especially since many of these related entities were seldom needed for some of the queries.

For instance, imagine you have a Store entity with a OneToOne relationship to a StoreDetail entity. The StoreDetail entity contains extensive metadata about the store that isn’t always needed. Even with lazy loading configured, Doctrine was fetching the StoreDetail entity by default, slowing down the application.

Photo by Ben Griffiths on Unsplash

The eager loading of StoreDetail affected performance significantly:

  • Increased Query Complexity: Queries included unnecessary JOIN operations to load the related StoreDetail entity.
  • Higher Memory Usage: Additional data was loaded into memory for every request, even when it wasn’t needed.
  • Slower Response Times: API endpoints that returned lists of stores suffered due to the overhead of loading related entities.

Initial configuration for the OneToOne relationship looked like this:

/**
* @ORM\Entity
*/
class Store
{
/**
* @ORM\OneToOne(targetEntity="StoreDetail", mappedBy="store", fetch="LAZY")
*/
private $detail;

// Getter and setter for $detail
}

Despite the fetch=”LAZY” configuration, Doctrine still eagerly loaded the StoreDetail entity, which was unexpected.

The first step was identifying why lazy loading wasn’t working as expected. After profiling queries using Symfony’s debug:doctrine command and tools like Doctrine DBAL Logger, I confirmed that a JOIN operation was being added automatically.

Through research and experimentation, I discovered that Doctrine’s proxy generation for OneToOne relationships might not behave as intended in certain scenarios. A promising alternative was to refactor the relationship into a ManyToOne, which offers better control over loading behavior.

Here’s how I resolved the issue step by step:

Step 1: Refactor the Relationship

I changed the OneToOne relationship into a ManyToOne by making StoreDetail reference the Store instead. This inversion of the relationship allows Doctrine to handle lazy loading more effectively.

Updated StoreDetail entity:

/**
* @ORM\Entity
*/
class StoreDetail
{
/**
* @ORM\ManyToOne(targetEntity="Store", inversedBy="details", fetch="LAZY")
*/
private $store;

// Getter and setter for $store
}

Updated Store entity:

/**
* @ORM\Entity
*/
class Store
{
/**
* @ORM\OneToMany(targetEntity="StoreDetail", mappedBy="store", fetch="LAZY")
*/
private $details;

public function __construct()
{
$this->details = new ArrayCollection();
}

// Getter and setter for $details
}

Step 2: Update Database Schema

I updated the database schema using Symfony’s Doctrine migrations:

php bin/console make:migration
php bin/console doctrine:migrations:migrate

This migration dropped the existing OneToOne foreign key and added a ManyToOne key.

Step 3: Adjust Queries

The new relationship required adjusting queries to reflect the changes. For instance, fetching store details became a separate query:

$store = $entityManager->find(Store::class, $storeId);
$details = $store->getDetails(); // Triggers lazy loading only when accessed

Step 4: Test the Changes

I wrote PHPUnit tests to ensure the lazy loading behavior was working as expected:

public function testLazyLoading()
{
$store = $this->entityManager->find(Store::class, 1);

// Assert $store is loaded
$this->assertNotNull($store);

// Assert $details are not loaded until accessed
$this->assertFalse($this->entityManager->getUnitOfWork()->isEntityScheduled($store->getDetails()));
}

Step 5: Profile and Verify

Using Symfony’s profiler, I validated that related entities were no longer eagerly loaded, confirming the performance improvement.

The impact of this optimization was substantial:

  • Query Execution Time: Reduced by 35% on average for endpoints that fetched store data.
  • Memory Usage: Decreased significantly, as unnecessary data was no longer loaded.
  • Response Times: Improved by 20–30%, particularly for API calls fetching large datasets.

For example, a previously slow query:

SELECT s.*, sd.* FROM store s LEFT JOIN store_detail sd ON s.id = sd.store_id;

Became this optimized query:

SELECT * FROM store WHERE id = ?;

With the StoreDetail entity fetched only when required.

Key takeaways:

  • Understand Default Behavior: There could be a chance that Doctrine’s lazy loading may not work in all cases and one would have to think in terms of architectural changes.
  • Refactor for Efficiency: At times, rethinking relationships, like changing OneToOne to ManyToOne, can eliminate an unexpected efficiency problem.
  • Profile Often: Use profiling tools to revalidate you query behavior assumptions and for spotting those bottlenecks.
  • Test Thoroughly: Ensure changes are tested in different environments to prevent regressions.

This experience taught me that even subtle design choices can significantly impact performance. By refactoring relationships and relying on Doctrine’s strengths, I was able to achieve a more efficient and scalable solution.

Let’s Keep in Touch!

Thank you for reading! I really appreciate your taking time to read through this post. Should you be curious about what else I’m working on, head over to mtccreatives.com.

While you’re there, you might as well be one of the subscribers to my newsletter. I share with you updates on my latest projects and the lessons I have learned, and ideas that could help inspire your work. It’s casual, insightful, and a great way for us to stay connected!

Do drop by! I would be happy to hear from you.

--

--

Marco Bruijns - MTC Creatives
Marco Bruijns - MTC Creatives

Written by Marco Bruijns - MTC Creatives

Software Engineer and MTC Creatives founder, bridging business and tech with scalable data solutions. Passionate about efficiency, AI, and real-world insights.

No responses yet