Doctrine Caches
The metadata, query and result caches, what each one stores, and the OPcache settings production needs.
$query->enableResultCache(300, 'sidebar_published_articles');Before Doctrine runs a query it does three things: read the mapping for the entities involved, compile the DQL into SQL, then execute it. Each step has its own cache, and they are not interchangeable.
Three caches
The metadata cache holds the parsed mapping: the result of reading your entity attributes into a description Doctrine can use. It changes only when your code changes. The query cache holds the SQL that a given DQL string compiles to; same DQL, same SQL, until you edit the query. Both are safe to cache for the lifetime of a deployment, and both should point at a cache pool in production.
The result cache is different in kind. It holds rows. It is off by default and enabled per query with enableResultCache(), which takes a lifetime and optionally an id you choose so you can target that entry later. This is the one that can serve wrong data.
Production settings
Turn off auto_generate_proxy_classes so Doctrine stops writing proxy files on the fly, and warm the cache during the build. Then let OPcache hold the compiled PHP: raise opcache.memory_consumption and opcache.interned_strings_buffer, set opcache.validate_timestamps=0, and preload through config/preload.php.
What trips people up
With validate_timestamps=0, PHP never notices changed files. A deploy that does not reset OPcache keeps running the old code.
Example
<?php
// src/Repository/ArticleRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Article>
*/
final class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
/**
* @return Article[]
*/
public function findForSidebar(\DateTimeImmutable $now, int $limit = 5): array
{
return $this->createQueryBuilder('a')
->andWhere('a.publishedAt IS NOT NULL')
// Bound parameter, never a value concatenated into the DQL.
->andWhere('a.publishedAt <= :now')
->setParameter('now', $now)
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
// Rows kept for 5 minutes under an id we choose, so this one
// entry can be targeted instead of the whole pool.
->enableResultCache(300, 'sidebar_published_articles')
->getResult();
}
}When to use it
- Keep a rarely changing sidebar list out of the database on every page view.
- Stop Doctrine re-parsing entity attributes and re-compiling DQL on every production request.
- Cut container boot time on a busy API by preloading the compiled container through OPcache.
More examples
Shorthand and longhand for the result cache
enableResultCache($lifetime, $id) is shorthand for enabling the cache and then setting the lifetime and id. The long form is what you need when the lifetime is computed, or when a shared query builder decides the id later. disableResultCache() turns it back off for one query.
<?php
// src/Repository/TagRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Tag;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Tag>
*/
final class TagRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Tag::class);
}
/** @return Tag[] */
public function shorthand(): array
{
return $this->createQueryBuilder('t')
->getQuery()
->enableResultCache(600, 'all_tags')
->getResult();
}
/** @return Tag[] */
public function longhand(): array
{
$query = $this->createQueryBuilder('t')->getQuery();
// The same three decisions, written separately.
$query->enableResultCache();
$query->setResultCacheLifetime(600);
$query->setResultCacheId('all_tags');
return $query->getResult();
}
}Pointing Doctrine's caches at pools
Each driver takes type: pool with a pool name, or type: service with the id of your own PSR-6 pool. cache.system is meant for things derived from your code and is cleared when the cache is warmed; cache.app is meant for data. Mixing them up is how a deploy either fails to pick up a changed query or needlessly throws away warm row data.
# config/packages/doctrine.yaml
when@prod:
doctrine:
orm:
# Proxies are generated during the build, not per request.
auto_generate_proxy_classes: false
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
# Code-shaped data: safe for the life of the deployment.
doctrine.system_cache_pool:
adapter: cache.system
# Row data: shares the application pool, so Redis in prod.
doctrine.result_cache_pool:
adapter: cache.appOPcache and preloading in production
Preloading keeps the compiled container and your entity classes resident between requests, which is where a large part of Doctrine's per-request cost goes. Pair it in the build with composer dump-autoload --no-dev --classmap-authoritative, so the autoloader stops scanning the filesystem for classes. The preload file path is absolute and must be the one inside the image you deploy.
; php.ini
; Compile the container and the classes it lists once, at start-up.
opcache.preload=/var/www/app/config/preload.php
opcache.preload_user=www-data
; Defaults are too small for a Symfony application.
opcache.memory_consumption=256
opcache.max_accelerated_files=32531
; Raised because fully qualified class names are interned strings.
opcache.interned_strings_buffer=32
; Files never change between deployments, so stop stat()ing them.
; The deploy must then restart PHP or call opcache_reset().
opcache.validate_timestamps=0
; Resolve paths once rather than on every include.
realpath_cache_size=4096K
realpath_cache_ttl=600
Discussion