Blog
Why Symfony applications become slow after years of development
Symfony applications usually slow down through years of small queries, listeners, serializers, synchronous integrations, oversized entities and caches without clear invalidation rules.
Where time goes in a mature Symfony application
A post list gains author names, then tags, then translated titles. A save operation acquires an audit listener and a search-index update. Each addition is useful, but together they change the cost of the original operation.
Accumulated work is one explanation for a slowdown, not a diagnosis. A poor execution plan, traffic spike, resource limit, deployment change or framework regression can also be responsible. The useful starting point is the complete request: what it reads, waits for, constructs and sends.
The examples below are hypothetical. They are not GiSoft benchmarks or reports of client incidents. PHP excerpts target PHP 8.2 or later because they use readonly classes; installed libraries and test tools may require a newer version. Project entities, mappings, imports, service wiring and test helpers are omitted unless shown.
Measure the operation the user experiences
Measure wall-clock response time alongside database work, hydration, normalisation or rendering, external calls, cache access and logging. Include routing, security, sessions and container initialisation rather than assuming that framework overhead is irrelevant. At the infrastructure boundary, distinguish application processing from queueing for a PHP worker and transferring the response to the client.
Symfony Profiler helps investigate individual requests in a protected development environment. An APM trace and database monitoring can reveal production behaviour without exposing the debug profiler publicly. Detailed collectors add overhead; compare releases with equivalent instrumentation and production-like settings.
This invented profile illustrates the questions to ask:
GET /api/{locale}/posts 20 returned posts
Response time 480 ms
Database 63 queries / 120 ms
Hydration 90 ms
Serialisation 180 ms
External calls 0
Peak memory 82 MB
Response body 1.8 MBDo not add these durations unless their measurement boundaries are known to be exclusive. A serialisation span may contain lazy-loading queries; concurrent HTTP spans may overlap. Database timings may include different amounts of fetching depending on the collector. The example points towards data loading and response construction, but a trace must establish the actual bottleneck. Reducing bootstrap time by five milliseconds would not explain away the rest.
Use representative data distributions, relation sizes and permissions, not only a large row count. Illustrative datasets of 50, 5,000 and 100,000 records can expose different query plans. Keep a page at 50 returned items while increasing the database population. Four queries at each scale still say nothing about rows scanned, sorting, hydration or transfer time.
By contrast, 52, 502 and 5,002 queries for 50, 500 and 5,000 returned items illustrate a possible N + 2 pattern. That is a different experiment: the result size grows too. Test cold and warm caches, realistic concurrency and the same EntityManager state separately.
Bound the read before choosing a loading strategy
This administrative listing deliberately has no limit:
$posts = $postRepository->findBy(
[],
['createdAt' => 'DESC', 'id' => 'DESC'],
);
$rows = [];
foreach ($posts as $post) {
$rows[] = [
'title' => $post->translationFor($locale)->getTitle(),
'author' => $post->getAuthor()->getDisplayName(),
'tags' => $post->getTags()->map(
static fn (Tag $tag): string => $tag->getName(),
)->toArray(),
];
}The excerpt assumes every post has an author and that translationFor() returns a translation for the requested locale or its agreed fallback. Missing values need an explicit policy. Tag and these entity methods belong to the example application.
Accessing display names, resolving translations and iterating tags may load relations. With uninitialised associations, a simplified model is one post query plus up to one query per post for each of those three paths. It is not a guaranteed 1 + 3N count: shared authors in the Identity Map, preloaded relations, mapping, caches and the translation method change the result. Relation access inside a loop is not itself a defect when the data is already loaded appropriately.
A list-specific DTO makes the expected output visible:
final readonly class PostListItem
{
/**
* @param list<string> $tags
*/
public function __construct(
public int $id,
public string $title,
public string $slug,
public string $authorName,
public array $tags,
public \DateTimeImmutable $publishedAt,
) {
}
}Here titles and author names are required, and published posts have an immutable publication date. The implementation must satisfy those assumptions. It can select scalar data into a projection or map deliberately loaded entities; a mapper that lazily visits each relation can reproduce the original problem.
The read contract stays narrow:
interface PostReadRepositoryInterface
{
/**
* @return list<PostListItem>
*/
public function findPublishedPage(
string $locale,
int $page,
int $limit,
): array;
}The signature alone does not enforce pagination, authorisation or locale fallback. Its implementation must validate the requested page, apply access restrictions and return a bounded result. PHPStan can check list<PostListItem> and its consumers, not the SQL cost.
DTOs are useful when the read needs only a few values, not compulsory or automatically faster. Managed entities remain appropriate when business operations need to modify them. Detailed loading alternatives are covered in the GiSoft article “N+1 is a data-access design problem”; this overview keeps their effect on total request cost in view.
Fetch only the relations this read needs
This repository excerpt fetches the author with the post:
/**
* @return list<Post>
*/
public function findPublishedWithAuthor(
int $limit,
): array {
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'Limit must be between 1 and 100.',
);
}
return $this->createQueryBuilder('post')
->addSelect('author')
->innerJoin('post.author', 'author')
->andWhere('post.published = :published')
->setParameter('published', true)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}addSelect('author') includes the joined entity in object hydration, making this a fetch join. The inner join excludes posts without an author; an optional author requires a suitable join and nullable result handling. The example assumes a to-one author relation and no other multiplying joins. The ID tie-breaker makes ordering deterministic for unchanged data.
A to-one join does not normally multiply root rows. Several independent collections can: a post with four translations, eight tags, twenty comments and three attachments could contribute 4 × 8 × 20 × 3 = 1,920 SQL rows if all combinations are joined. Filters and join conditions change that figure. These are not 1,920 distinct posts, but transferring rows and assembling unique objects still costs work. DISTINCT does not erase combinations with different child values.
For to-many fetch joins, a raw limit can restrict SQL rows rather than complete root entities. Use an appropriate Doctrine paginator or a deliberate two-phase read; do not copy pagination settings between unlike queries.
Select page IDs, then load their details
The following methods are project-specific, not Doctrine APIs:
$postIds = $this->posts->findPublishedIds(
locale: $locale,
page: $page,
limit: $limit,
);
$items = $postIds === []
? []
: $this->posts->findListItemsByIds(
ids: $postIds,
locale: $locale,
);The first phase must select unique IDs with consistent filters, permissions and stable ordering. The second must preserve those restrictions and return items in that order: IN (:ids) alone does not preserve the input sequence. It must load the required relations in bounded batches or projections, not call a query for every ID. The empty-page branch avoids a pointless detail query.
Two phases may involve more than two SQL statements, particularly with totals or several collections. They can reduce row multiplication but add round trips. If concurrent updates between phases matter, define the required snapshot or transaction consistency.
Pagination, serialisation and Twig share the same budget
An unbounded read such as this can outgrow its original screen:
$repository->findAll();A hypothetical table growing from 200 to 400,000 rows makes its risk clear. The number returned, not just the table size, drives object allocation and rendering. Pagination belongs in the server-side read contract.
This request DTO uses a project-specific maximum of 100:
final readonly class PageRequest
{
public function __construct(
public int $page = 1,
public int $limit = 25,
) {
if ($page < 1) {
throw new \InvalidArgumentException(
'Page must be greater than zero.',
);
}
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'Limit must be between 1 and 100.',
);
}
}
}Parse and validate HTTP parameters before constructing it. PHP property types do not fully validate raw query strings. Apply the limit in SQL, use a stable tie-breaker and consider the cost of deep offsets and count queries. Keyset pagination can help sequential browsing, but does not directly provide arbitrary page jumps.
Symfony Serializer may access entity getters and traverse relations depending on its normalisers, groups and context. That can add queries, large payloads or unintended field exposure. It is not a universal consequence of encoding an entity: plain json_encode() does not automatically traverse all private associations. Groups select fields; they neither plan loading nor replace authorisation.
For a public response, an explicit representation can help:
final readonly class PublicPostDto
{
/**
* @param list<string> $tags
*/
public function __construct(
public string $title,
public string $slug,
public string $excerpt,
public array $tags,
) {
}
}Populate only fields the caller may see and keep the mapping bounded. A DTO built from an oversized graph does not recover the time already spent loading it.
Twig can expose the same read-path issue:
{% for page in pages %}
<h2>{{ page.translationFor(app.request.locale).title }}</h2>
<span>
{{ page.parent.translationFor(app.request.locale).title }}
</span>
{% for feature in page.features %}
{{ feature.translationFor(app.request.locale).name }}
{% endfor %}
{% endfor %}This excerpt assumes a parent and the required translations exist; a real view must handle missing parents and translations according to its display policy. Accessors can initialise unloaded relations, but not every property read produces SQL. Deliberately loaded entities may be sufficient; a PageAdminRow view model is another option. Include rendering or normalisation in an end-to-end query measurement, not just the repository call.
Find the synchronous work outside the controller
Symfony's usual EventDispatcher calls listeners synchronously. A listener can instead enqueue work, but the event name alone says nothing about asynchronous execution or durable delivery. Audit writes, translation updates, cache invalidation, indexing and webhooks can therefore extend a request without appearing in its controller.
Doctrine lifecycle events follow their own rules. prePersist, postUpdate and postLoad do not all run on every flush. For example, postUpdate runs for relevant ORM updates inside flush(), before transaction commit; it is not an after-commit notification. postFlush also does not prove that an enclosing transaction has committed. Check the installed ORM version before changing hook behaviour. Re-entering flush() from a flush-triggered listener is not a safe general persistence technique.
Keep persistence-related hooks small where possible. Make expensive orchestration visible in a service or handler that can be profiled and tested. This is a responsibility decision, not a reason to add layers to every operation.
External tracking calls are another easily hidden cost:
$rows = [];
foreach ($orders as $order) {
$tracking = $shippingClient->getTracking(
$order->getTrackingNumber(),
);
$rows[] = $this->mapper->map($order, $tracking);
}getTracking() and the mapper are illustrative project contracts, not a verified shipping SDK. Assuming blocking calls, 50 orders at a hypothetical 100 ms each contribute roughly five seconds before other work. Lazy or concurrent clients require a different measurement.
Depending on the provider and freshness requirements, consider a documented batch endpoint, a periodically synchronised local snapshot, caching, loading details on demand or bounded concurrency. Do not assume a provider supports batching. Limit concurrency and timeouts, honour rate limits and define what the user sees when tracking is unavailable.
Container access can obscure which dependency caused the work:
$service = $container->get('some_service');Constructor injection makes those dependencies easier to inspect:
final class ProductImportService
{
public function __construct(
private readonly ProductRepositoryInterface $products,
private readonly ProductMapperInterface $mapper,
private readonly ImportMetricsInterface $metrics,
) {
}
}This is a declaration excerpt with project-owned interfaces. Injection is not inherently faster than Symfony container retrieval, and retrieving a shared service does not necessarily construct it again. Its value here is making the import's data access, mapping and instrumentation visible.
Move work off the request without losing it
A queue changes when and where the cost is paid. It does not eliminate database load, provider charges or CPU work. Indexing, report generation, notifications and media conversion can be suitable background tasks when the product accepts delayed completion. Required permissions and the conditions for reporting success still belong at the appropriate decision boundary.
A transactional outbox is one option when the business change and intended follow-up must survive together:
HTTP request: validate input and permissions
Local transaction
Save business state
Save outbox record
COMMIT
Response may return
Publisher may send committed outbox records
-> transport -> worker
bounded retries / duplicate handlingBoth records must participate in the same suitable database transaction. After commit, response delivery and publication have no required order relative to each other. The publisher and consumer may repeat work, so an outbox does not imply exactly-once execution. Monitor publication lag, queue age, failures and worker capacity as well as HTTP latency.
With an independent transport, dispatch before commit can let a worker see a message before its data is visible. A transport write enlisted in the same transaction behaves differently. Deferring an in-memory dispatch until after commit addresses ordering but leaves a crash window before publication. See the GiSoft guide “The order was processed. Why did the message return?” for retry, concurrency and external-effect recovery; those mechanisms are not repeated here.
Logging and sessions can make requests wait
Large log contexts add storage and processing costs:
$this->logger->info('Imported product', [
'product' => $product,
'request' => $request->request->all(),
'response' => $providerResponse,
]);Whether passing an entity triggers normalisation or lazy loading depends on formatters, processors and object behaviour. It is not automatic. Regardless, complete request or provider payloads are poor defaults for both cost and confidentiality.
A bounded entry is easier to use:
$this->logger->info('Product import completed', [
'productId' => $product->getId(),
'provider' => $providerName,
'durationMs' => $durationMs,
'status' => 'completed',
]);The duration, provider label and identifiers need to be collected deliberately. Keep values bounded and omit credentials, tokens, sensitive payment fields and unnecessary personal data. Sampling and log levels should preserve useful failure evidence rather than suppress everything.
Session contention is separate. If the configured handler locks a session, concurrent requests sharing it may wait for a slow request to release it. Other handlers or settings may behave differently. Avoid starting sessions unnecessarily; where supported, save and close them before long session-independent work only after checking later writes, authentication and CSRF behaviour. A global switch to stateless security is not a performance fix to apply blindly.
Cache needs a freshness policy, not a fallback diagram
These are key templates, not literal keys or a complete access-control design:
settings.public.{tenant}.{locale}
menu.{tenant}.{category}.{locale}.{audience}
page.{tenant}.{pageId}.{locale}.{audience}
post.list.{tenant}.{locale}.{page}.{limit}.{variant}Expand placeholders into valid backend keys. audience must represent all relevant visibility rules; a role name may not capture individual permissions. variant represents a canonical encoding or hash of filters, sort order and visibility context. Include other inputs that change the result, or avoid sharing the entry. A single-tenant application with genuinely public data needs fewer dimensions.
Invalidate affected pages, translations and menus after the successful business transaction, with a recovery path if invalidation fails. A TTL limits an entry's lifetime and should reflect acceptable staleness; it does not make a missed invalidation harmless. If freshness is critical, design for races between rebuilding and invalidation, for example with versioned keys. Broad clearing can create a burst of expensive rebuilds; request coalescing or supported stampede protection can help.
Explicit cached data is often easier to control than arbitrary ORM object graphs:
final readonly class PublicSettingsDto
{
public function __construct(
public string $companyName,
public string $slogan,
public string $phone,
) {
}
}Caching a PHP entity is not impossible, but restoring it from an application cache does not restore its original managed relationship with an EntityManager. Proxies, partial relations and deployment compatibility need attention. Doctrine's own result or second-level cache has different semantics from storing arbitrary entities in an application cache.
Redis, Memcached and filesystem storage are alternatives, not an automatic resilience chain. A shared interface does not establish failover semantics. Symfony's ChainAdapter is a configured multi-tier cache, not a universal guarantee of transparent recovery from backend outages. Any fallback must account for TTL, invalidation across nodes, stale values, permissions and capacity. Depending on the data, a bounded direct read, an explicitly permitted stale value or a controlled error may be preferable.
Measure cache operation latency, rebuild time, payload size, backend errors, invalidation and freshness alongside hit ratio. A hypothetical 95% hit rate can coexist with very slow misses or stale responses.
Forms, translations and media deserve focused checks
Large EntityType choice lists, nested collections, form listeners and validation can load much more than the visible form suggests. Server-side filtering, loading dependent choices later or autocomplete can help; not every form needs a new API. Submitted IDs still require existence and access checks.
Repeated translation resolution can also do hidden work:
$page->translationFor($locale);
$feature->translationFor($locale);
$category->translationFor($locale);These are project methods, not a universal Symfony translation API. They may scan an already loaded collection, initialise one or apply fallback logic. A read query can select the requested locale directly. This illustrative SQL assumes a custom schema with one translation per (page_id, locale), integer publication flags and integer-bound pagination parameters:
SELECT
page.id,
translation.title,
translation.slug
FROM pages__page page
INNER JOIN pages__page_translation translation
ON translation.page_id = page.id
AND translation.locale = :locale
WHERE page.published = 1
ORDER BY page.sequence ASC, page.id ASC
LIMIT :limit OFFSET :offset;The inner join omits pages without that locale. Keeping those pages or using a fallback language requires a deliberate alternative, such as appropriate left joins or resolution in the read service. Apply tenant and permission filters where needed. Index choice follows the actual plan and workload, not the table names in the sketch.
For media, distinguish slow PHP processing from slow browser downloads. Oversized originals, repeated filesystem checks and uncached image conversion require different fixes. Validate uploads, keep allowed derivative sizes bounded and reuse generated files. Generate them during controlled upload processing or asynchronously when justified; an inexpensive cached on-demand thumbnail may also be suitable. Appropriate HTTP caching and responsive formats can improve delivery without rebuilding the backend.
Turn findings into budgets and regression tests
Choose work by frequency, latency, resource pressure and business impact. A hypothetical endpoint serving 20% of traffic at P95 1.8 s, with 74 queries, 140 MB peak memory and a 4.2 MB response would merit investigation. Those figures are illustrative, not observed together in a GiSoft deployment. A rare report can still take priority if it blocks a critical process.
This YAML expresses possible project budgets. Symfony does not implement it automatically:
performance_budget:
public_page:
max_application_queries: 8
max_response_bytes: 300000
target_p95_ms: 400
max_records_per_page: 50
admin_listing:
max_application_queries: 12
max_records_per_page: 100
target_p95_ms: 800Set limits from requirements and measured baselines, including data volume and load conditions. Query and body-size limits can run in CI; percentile targets need a representative observation window and workload. Neither four queries nor 300,000 bytes is a universal threshold.
The following Pest examples use project-specific createPublishedPosts(), postQuery() and startApplicationQueryCollection() helpers. Setup must flush fixtures, control or clear the EntityManager and establish the intended cache state before counting. Resolve the service before starting the collector; stop or reset collection between isolated tests. The collector must capture the relevant connection's SQL, including data mapping, but exclude fixture creation and bootstrap.
First, the page contains all 50 fixture posts:
it(
'meets the query budget with 50 posts',
function (): void {
$this->createPublishedPosts(count: 50);
// Fixtures are flushed; ORM and cache state are controlled.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);Then the database contains 500 posts while the page remains at 50:
it(
'meets the same page budget with 500 posts',
function (): void {
$this->createPublishedPosts(count: 500);
// Fixtures are flushed; ORM and cache state are controlled.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);Both assert the same upper bound and result size; they do not prove equal counts, constant runtime or concurrency performance. Representative fixtures should include authors, translations and tags that exercise the loading paths. Separate benchmarks can vary page size, relation sizes and concurrent load. Avoid microsecond assertions in ordinary CI.
A response-size test protects a different boundary:
it(
'keeps the public post response bounded',
function (): void {
$client = static::createClient();
// Prepare representative fixtures and any required access.
$client->request(
'GET',
'/api/en/posts?page=1&limit=25',
);
self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
expect($content)->toBeString();
expect(strlen($content))->toBeLessThan(300_000);
},
);This assumes Pest is configured with a Symfony WebTestCase base, representative published fixtures, suitable access and the illustrated route. The body is a string, not a streamed response. strlen() counts the test response's bytes, not necessarily compressed bytes on the network. Also test invalid page values, excessive limits, empty results and stable ordering; this snippet does not cover them.
Static checks, review and production evidence
The list<PostListItem> return contract shown earlier gives PHPStan useful information about elements and consumers. Analysis can catch incompatible types, unsafe nullable access and inconsistent shapes, depending on configuration and extensions. It cannot measure N+1 or automatically prohibit repository dependencies and entity serialisation. Hiding uncertainty behind mixed removes useful information.
Architecture tests can encode specific agreed restrictions:
arch('API controllers do not use EntityManager')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('core does not depend on the external SDK')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');These examples use Pest's architecture API; Vendor\ExternalSdk is a placeholder. Confirm tool versions, namespaces and that the rules actually inspect the intended classes. They detect selected dependencies, not every indirect SQL call, return value or runtime performance defect.
Apply the same review to human and AI-assisted changes. Check actual repository behaviour, expected volume, relation access, pagination, external calls and cache invalidation. Request focused evidence rather than an invented query estimate. Neither clean syntax nor the use of a DTO proves predictable cost, and AI-generated code is not inherently slow.
In production, track request rates and P50/P95/P99 latency by route template, database duration and query fingerprints, external-call latency, response size, memory and errors. Record hydration separately only where instrumentation supports it. For background work, include queue delay and processing time.
Use bounded labels such as operation type and controlled release identifiers. Tenant IDs, raw URLs, operation IDs and user values can create high-cardinality metrics; keep necessary detail in access-controlled, sampled traces rather than adding every value as a metric label.
A comparison record might look like this; it is a conceptual schema, not a proposed migration:
performance_snapshot
id, operation, application_version, captured_at
dataset_record_count, record_count, page_size
query_count, database_duration_ms
hydration_duration_ms, external_duration_ms
serialization_duration_ms, total_duration_ms
peak_memory_bytes, response_bytes
cache_state, measurement_scopeHere record_count means returned items, distinct from total dataset size. An APM platform can hold the same evidence. Compare equivalent hardware, workload, instrumentation and cache state; do not sum inclusive spans or mistake one sample for a percentile.
A practical investigation ends with one bounded change, a regression check, a comparable before-and-after profile and monitoring after release. Broad eager loading, blanket caching, static helpers or a rewrite are not substitutes for locating the cost. The aim is predictable work and a development process that notices regressions before they become routine.
Technical references
- Symfony Profiler — https://symfony.com/doc/7.4/profiler.html
- Doctrine pagination — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/tutorials/pagination.html
- Doctrine lifecycle events — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/events.html
- Symfony cache and cache chains — https://symfony.com/doc/current/cache.html
- Symfony sessions — https://symfony.com/doc/7.4/session.html
- Pest architecture tests — https://pestphp.com/docs/arch-testing
- PHPStan PHPDoc contracts — https://phpstan.org/writing-php-code/phpdoc-types
