Shopware 6 Hosting Optimization: PHP-FPM, OPcache, and Redis Configuration
The Shopware docs tell you opcache.max_accelerated_files should be at least 20000. They don’t say how much memory OPcache should get. On PHP-FPM pool size they say nothing at all. Most setups fill those gaps with defaults or values copied from a WordPress tutorial.
This post picks up where the hosting post ends. That one was about choosing the server, this one is about the settings afterwards. All examples assume a single root server with 16 GB of RAM, Shopware 6.7, PHP 8.3, and nginx.
PHP-FPM: calculate the pool size from RAM
pm.max_children is the most important number on the whole server. Too low, and visitors queue while RAM sits idle. Too high, and a traffic spike wakes the OOM killer, which tends to pick MariaDB.
The math is simple: the RAM left for PHP divided by memory per worker. On a 16 GB server with everything on one machine, the budget looks like this: about 1 GB for the OS and nginx, 4 GB of InnoDB buffer pool for MariaDB, 1 GB for Redis. That leaves roughly 10 GB for PHP-FPM. If OpenSearch runs on the same box, subtract its heap too.
The 4 GB for MariaDB isn’t a law of nature. The MariaDB docs say the buffer pool should hold the active data set, and that a few gigabytes is a good start. This query shows how big your shop database is:
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024) AS mb
FROM information_schema.tables
WHERE table_schema = 'shopware'; -- your shop database name
If it’s well under 4 GB, give the rest to PHP. If it’s larger, MariaDB gets more and PHP less.
Measure memory per worker on the running server instead of guessing:
ps --no-headers -o rss -C php-fpm | awk '{sum+=$1} END {print sum/NR/1024 " MB"}'
The number depends heavily on your plugins. Use 80 MB for a first draft and replace it with your measurement once the shop has traffic. A common mistake is dividing by memory_limit instead. At 512 MB, 10 GB gets you 20 workers, and the pool is full at the first traffic spike. memory_limit caps the occasional outlier. It isn’t normal usage. Large imports run through the message queue on the CLI anyway, not through FPM.
With 10240 MB, a 10 % buffer, and 80 MB per worker, the PHP-FPM pool calculator gives you these values:
; /etc/php/8.3/fpm/pool.d/www.conf
[www]
pm = dynamic
pm.max_children = 115
pm.start_servers = 29
pm.min_spare_servers = 29
pm.max_spare_servers = 58
pm.max_requests = 500
pm.status_path = /fpm-status
pm.max_requests = 500 restarts each worker after 500 requests, which limits the damage when a plugin leaks memory. Expose the status page in nginx to 127.0.0.1 only. It shows a max children reached counter, and after a week of traffic that counter is the most honest answer to whether 115 fits. If it’s at 0, the value is a ceiling you never hit, which is fine. If it climbs, the FPM log will show server reached pm.max_children setting. Check that RAM is actually free before you raise it.
OPcache: three values and one mistake
; /etc/php/8.3/fpm/conf.d/99-shopware.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=20
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
zend.assertions=-1
realpath_cache_size=4096K
realpath_cache_ttl=3600
PHP defaults opcache.memory_consumption to 128 MB. That’s tight for Shopware, because the compiled Symfony container from var/cache/ ends up in OPcache next to vendor/. 256 MB is also what the Shopware installer recommends, and Shopware’s official Docker image uses the same values as above, validate_timestamps=0 included. cachetool tells you whether 256 MB is enough: run cachetool opcache:status. If Cache full says Yes, new files no longer get cached. Oom restarts counts how often OPcache flushed itself completely because of that. If either one looks off, go to 512.
For opcache.max_accelerated_files, PHP rounds up internally to the next prime from a fixed list, so 20000 becomes 32531 slots. Count what you actually have once:
find vendor var/cache custom -name '*.php' | wc -l
If you’re above 32531, set it to 60000.
The real mistake is opcache.validate_timestamps. PHP defaults it to 1, which means PHP keeps checking whether each file changed, every two seconds by default. On a dev machine that’s right. On a live shop with tens of thousands of files, those are filesystem calls with no purpose, because the code only changes on deployment.
With validate_timestamps=0 you have to watch exactly one thing: after each deployment, PHP keeps serving the old code until you clear OPcache. So systemctl reload php8.3-fpm or cachetool opcache:reset belongs at the end of every deploy script. When you install a plugin through the Admin, Shopware clears OPcache itself. A bin/console plugin:install on the command line only reaches the CLI’s OPcache, not FPM’s, so you need the reload after that too.
I leave opcache.preload off. The docs promise a 2–5 % gain. In exchange every cache clear needs an FPM restart, and the Extension Manager stops working. For most shops that’s a bad trade.
Redis: two instances instead of one
The Shopware docs sort Redis data into three classes. Caches can be rebuilt at any time. Sessions matter but lose value over time. Carts and number ranges can’t be recovered: lose the order number counter and Shopware hands out duplicate numbers.
So a single Redis instance for everything doesn’t fit. When the cache fills up, it can push carts out. And you’d have to turn on persistence for the whole instance when only a small part of the data needs it. For a single-server shop, two instances are enough, a volatile one for cache and a persistent one for everything else:
# /etc/redis/redis-cache.conf
port 6379
maxmemory 512mb
maxmemory-policy volatile-lru
save ""
appendonly no
# /etc/redis/redis-persistent.conf
port 6380
maxmemory 512mb
maxmemory-policy volatile-lru
appendonly yes
The docs actually recommend a third instance with allkeys-lru for sessions. On a single server with one shop, I put them in the persistent instance, just in their own database. That works because sessions and carts get an expiry in Redis and number ranges don’t. When memory runs short, volatile-lru drops old sessions and abandoned carts first and never touches the order number counters.
The connections go in .env:
REDIS_CACHE_URL=redis://127.0.0.1:6379/0
REDIS_PERSISTENT_URL=redis://127.0.0.1:6380/0?persistent=1
REDIS_SESSION_URL=redis://127.0.0.1:6380/1
?persistent=1 means connection pooling, not data persistence. Since 6.6.8 you define Redis connections once by name and reference them. The old redis_url and dsn keys are deprecated in 6.7 and go away in 6.8:
# config/packages/shopware.yaml
shopware:
redis:
connections:
ephemeral:
dsn: '%env(REDIS_CACHE_URL)%'
persistent:
dsn: '%env(REDIS_PERSISTENT_URL)%'
cart:
storage:
type: redis
config:
connection: persistent
number_range:
increment_storage: redis
config:
connection: persistent
cache:
invalidation:
delay_options:
storage: redis
connection: ephemeral
increment:
user_activity:
type: array
user_activity remembers which Admin modules a user opened last. Every update briefly locks the table, and according to the docs that slows down queue workers when several run in parallel. If you don’t need the module usage overview, the docs recommend array.
Cache and sessions go through Symfony:
# config/packages/framework.yaml
framework:
cache:
app: cache.adapter.redis_tag_aware
system: cache.adapter.redis_tag_aware
default_redis_provider: '%env(REDIS_CACHE_URL)%'
session:
handler_id: '%env(REDIS_SESSION_URL)%'
If you switch a running shop, carts and number ranges still live in the database. Without a migration, customers lose their carts and order numbers start over in Redis. So the order is: maintenance mode on, roll out the config, then migrate:
bin/console sales-channel:maintenance:enable --all
# roll out the new config, clear the cache
bin/console number-range:migrate mysql redis
bin/console cart:migrate sql redis://127.0.0.1:6380/0
bin/console sales-channel:maintenance:disable --all
The number range command warns on its own that it can produce duplicate numbers under load. Don’t skip the maintenance mode.
Two more things. The Redis cache instance can fill up despite maxmemory, because cache tags without an expiry pile up. The docs point to FroshTools or shopware-redis-cli-helper for regular cleanup. And the PHP extension: php --ri redis | grep Version shows which php-redis version is installed. From 6.7.7.0 Shopware recommends 6.1 or newer, and Ubuntu 24.04 ships 5.3.7. Composer doesn’t check this, so you have to look yourself. More in the 6.6 to 6.7 upgrade post.
After a week, look at four values: max children reached on the FPM status page, Cache full and Oom restarts in the OPcache status, and used_memory against maxmemory in both Redis instances. If there’s headroom everywhere, the setup fits.