Symfony Performance Audit: Profiling, Cache Tuning, and Query Optimization Step by Step

#symfony performance audit
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

A Symfony performance audit is not a vague "look for slow things" exercise. It is a structured sequence: instrument, measure, identify the constraint, fix it, measure again. When you skip any step, you end up with a collection of micro-optimizations that add up to nothing the user notices.

This guide walks through that sequence in the order we actually run it on client codebases - from getting the Symfony Profiler and Blackfire installed and readable, through the five problems that account for the majority of the slowdowns we find, to the before-and-after measurement workflow that turns "feels faster" into a number you can show a stakeholder.

Setting Up Profiling Before Touching a Line of Code

Every audit starts the same way: do not guess, measure. The Symfony Profiler is the entry point because it is already in every Symfony project that has symfony/profiler-pack installed. If it is not installed, add it:

composer require --dev symfony/profiler-pack

The toolbar at the bottom of each page gives you a quick read: time, memory, database query count. The number that matters most at this stage is not the total time - it is the query count. More than 20 queries on a typical page is a flag. More than 50 is a problem. More than 100 means there is an N+1 query issue and it is probably not the only one.

For anything beyond the toolbar, you need the profiler detail view at /_profiler. Click through to the Doctrine panel and sort queries by time descending. The slowest queries are not always the problem - a query that runs in 2ms but executes 200 times per request is worse than a single 50ms query that you can cache.

Blackfire extends this significantly. It records a full call graph with exclusive and inclusive timings per function, which lets you see exactly where wall time is actually spent rather than inferring it from query counts.

Install the Blackfire PHP extension and probe, then run a profile from the browser extension or CLI:

blackfire curl https://yourapp.local/your-slow-route

The profile timeline is where you spend most of your audit time. Look for three things: wide horizontal bars (slow functions), tall vertical stacks (deep call chains), and repeated patterns (the same function called many times). The last one is how N+1 manifests in Blackfire - you will see Doctrine\ORM\EntityManager::find called dozens of times in a loop.

The Five Problems Found Most Often in Real Symfony Codebases

These are not theoretical. They are the issues that come up repeatedly when auditing Symfony applications that were built under deadline pressure or by developers who learned Doctrine from documentation examples rather than production codebases.

1. Missing Lazy Loading Initialisation on Collections

Doctrine lazy-loads collection relationships by default. A OneToMany collection on an entity is a proxy until it is accessed. The problem is that accessing it in a loop - rendering a list of users with their roles, for example - triggers a separate query per entity. This is the classic N+1 and it is more common than any other issue we find.

The fix is explicit eager loading with a DQL join:

// Before: triggers one query per user
$users = $userRepository->findAll();
foreach ($users as $user) {
    echo count($user->getRoles()); // N queries here
}

// After: one query with JOIN FETCH
$users = $em->createQuery(
    'SELECT u, r FROM App\Entity\User u LEFT JOIN FETCH u.roles r'
)->getResult();

The performance difference is not marginal. On a list of 200 users, we routinely see a drop from 200+ queries to 1, taking a 1,200ms response down to 80ms.

The important detail is that JOIN FETCH is not always the right answer. For very large collections or when you only need counts, a separate aggregated query or a EXTRA_LAZY fetch mode is more appropriate. EXTRA_LAZY adds fetch="EXTRA_LAZY" to the mapping annotation and defers collection loading until the collection is specifically iterated or counted - but it still triggers a query per entity if you iterate, so it does not fix a loop.

2. N+1 Queries in Form Types

Form types are an underappreciated source of N+1 queries. An EntityType field in a form renders an HTML select populated from a query. When that form appears in a list context - for example, an admin table where each row has an inline edit form - Doctrine fires one query per form instance.

The solution is to pre-load the choices outside the form and pass them in:

// In your controller or a form event subscriber
$choices = $categoryRepository->findAll();
$form = $this->createForm(ProductType::class, $product, [
    'category_choices' => $choices,
]);

Then in your form type, replace the EntityType with a ChoiceType that uses the pre-loaded array. One query, regardless of how many forms render.

3. Over-Eager Doctrine Event Listeners

Symfony's event system is powerful and easy to misuse. We regularly find listeners registered on postLoad or prePersist that do work unconditionally - fetching related entities, calling external services, calculating derived values - even when the event fires for entities where none of that work is needed.

The symptom in Blackfire is a listener method that appears dozens of times per request, high up in the call stack, with significant cumulative time. The fix is almost always to add an early return:

public function postLoad(LifecycleEventArgs $args): void
{
    $entity = $args->getObject();
    if (!$entity instanceof ProductEntity) {
        return; // Most listener executions exit here
    }
    // ... actual work
}

This sounds obvious but the code smell is usually the result of a listener being added to handle one entity type and then the application growing to have twenty entity types. The listener's cost scales with entity count, not with intent.

For listeners that must always run but are expensive, consider moving the work to a message queue via Symfony Messenger rather than doing it synchronously in the request lifecycle.

4. Synchronous HTTP Calls Inside the Request Lifecycle

External API calls that happen synchronously in a controller or service are a reliable source of slow response times because they make your application wait on infrastructure you do not control. The symptom in the Symfony Profiler is obvious: a high "app" time that is mostly idle, and an HTTP client entry in the profiler that accounts for 300-800ms.

The first question to ask is whether the call needs to be in the request at all. If you are fetching data that changes infrequently (exchange rates, feature flags, external product catalogs), cache the response with the Symfony Cache component:

$cachedData = $cache->get('external_api_data', function (ItemInterface $item) {
    $item->expiresAfter(300); // 5-minute TTL
    return $this->httpClient->request('GET', 'https://api.example.com/data')->toArray();
});

If the call must happen on every request because the data is user-specific and real-time, move it to a non-blocking pattern. Symfony HttpClient supports async requests natively:

$response = $httpClient->request('GET', 'https://api.example.com/user-data');
// Do other work here while the request is in flight
$data = $response->toArray(); // Blocks only when you need the result

If the call triggers a side effect (sending a notification, logging to an external service, updating a third-party system), it belongs in a Messenger message, not in the request.

5. Unindexed Foreign Keys on Joined Tables

Doctrine migrations do not add indexes to foreign key columns by default in all configurations. The result is that JOIN queries that look fast in development (small dataset) become table scans in production. A query joining orders to order_items on order_id without an index on order_items.order_id will scan the entire order_items table for each row in orders.

The diagnostic is a slow query log or the PostgreSQL EXPLAIN ANALYZE / MySQL EXPLAIN output for the specific query. Look for Seq Scan in PostgreSQL or type: ALL in MySQL on the joined table - that is a missing index.

The fix in Doctrine is straightforward:

#[ORM\Column]
#[ORM\Index(name: 'idx_order_items_order_id', columns: ['order_id'])]
private int $orderId;

Then generate and run the migration. On a table with 500k rows, adding this index has turned 8-second queries into 12ms queries.

Reading the Profiler Timeline Correctly

The Symfony Profiler and Blackfire both produce timelines, but they measure different things. The Symfony Profiler shows wall time broken into segments: kernel request, controller, response. It is good for identifying which phase of the request is slow. Blackfire shows a call graph with function-level timing. It is good for finding the specific code responsible.

The pattern that misleads most developers is the distinction between exclusive and inclusive time in Blackfire. Inclusive time for a function includes all time spent in functions it called. Exclusive time is the time the function itself spent executing, excluding children. A function with high inclusive time but low exclusive time is a symptom - the real cost is in one of its callees. Follow the chain down until you find a function with high exclusive time. That is the actual problem.

For Doctrine, the most useful view is the query timeline sorted by count then by time. A query that appears 1 time and takes 200ms is a different problem from a query that appears 200 times and takes 1ms each. The former needs query optimization (index, restructured JOIN, or caching). The latter needs a loader change (JOIN FETCH or batch loading).

The Before-and-After Measurement Workflow

An audit without measurements is just refactoring with opinions. The workflow we use:

First, establish a baseline. Use a consistent testing environment - same hardware or the same cloud instance type, same database size, no caching layers active. Run at least five requests to the target route and record the median response time and query count from the profiler. Do not use the first request (cold start) or the best request (lucky cache hit).

Second, make one change at a time. Fix one issue, measure again with the same methodology. This tells you the actual impact of each fix rather than a combined number you cannot explain.

Third, measure with production-realistic data. An N+1 on a table with 50 rows is invisible in development but catastrophic on a table with 50,000 rows. Either restore a production database snapshot to your staging environment or generate realistic data volumes before drawing conclusions.

Fourth, document the numbers. A before-and-after table with route, query count, median response time, and p95 response time gives you something concrete to share with a team or a client. "We reduced query count from 247 to 3 on the product listing page, dropping p95 response time from 1,800ms to 95ms" is a meaningful result. "We optimized the database queries" is not.

When a Performance Audit Becomes Something More

Sometimes a Symfony performance audit reveals that the issue is not code - it is architecture. A Symfony monolith with 600 Doctrine entities, 1,200 event listeners, and a service container that resolves 800 services per request will be slow regardless of how well the individual queries are optimized. The profiler will show it: every request touches everything.

At that point the conversation shifts from "which query to optimize" to "what needs to be extracted, split, or cached at the infrastructure level." That is a different engagement from a targeted audit.

If you are seeing these symptoms - audit findings that are superficially fixable but come back in a month because the underlying structure produces them continuously - it is worth a broader architectural review. The code quality consulting and legacy code optimization services at Wolf-Tech are designed for exactly this scenario: a structured assessment that tells you whether to tune what you have or change how it is built.

For targeted performance problems that are clearly within a single route or service, a focused audit and fix engagement is usually sufficient. Reach us at hello@wolf-tech.io or through wolf-tech.io to describe what you are seeing and we can tell you quickly which type of engagement fits.

The profiler tells you what is slow. The fix is usually straightforward once you know what to look for. The hard part is building the measurement discipline to confirm it worked - and to catch the next problem before it reaches production.