Performance and Caching

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against PrestaShop 9.2.0 (released 2026-09-30, Hummingbird 2.1.2 as the default theme, Smarty 4.5.7, Symfony 6.4.46, PHP 8.5.12 and Apache 2.4.68 in the official Docker image, MySQL 8.4.13) and checked on 2026-10-27. PrestaShop is a self-hosted platform: nobody tunes it for you, so the speed of a shop is the sum of the decisions of whoever hosts it. Most of the settings below exist unchanged in PrestaShop 8.x and 1.7, where the same advice applies; where 9.x differs the page says so.

A PrestaShop request crosses five layers that can each answer from a cache: the browser and any CDN, the web server, PHP, PrestaShop itself and the database. This page goes through them in that order after a section that shows what they are worth, with numbers measured on the Pocket Coffee test shop, and ends with a production checklist. It also sorts out a few statements that the official documentation contradicts itself on (which Smarty setting matters, whether the MySQL query cache exists) and flags advice from older books and posts that no longer applies. It follows the themes page (whose assets the CCC settings combine) and the CLI commands page (which documents the console commands used here), and precedes the Security page.

The running example is Pocket Coffee: a small shop with 19 demo products, two languages and a handful of modules. Its home page, the Hummingbird theme with the main menu, the featured products and the newsletter block, is the page that every test below requests. The PHP backing PrestaShop is a monolith with a large number of small PHP files, a legacy front office (controllers, Smarty templates, ObjectModel) and a Symfony back office and Admin API; the two halves respond to performance settings very differently, as the measurements show.

What the settings are worth

The layers

Where a PrestaShop 9 request can be served from a cache: browser and CDN, web server, PHP, PrestaShop and database, with the setting that controls each

Measurements

Numbers help only when they come with their method. The test shop ran in two containers of the official image against the same MySQL database: the shared development container (PS_DEV_MODE=1, port 8080) and a production-mode copy (PS_DEV_MODE=0, on a copy of the shop’s volume so that changing app/config/parameters.php or the PHP configuration could not affect anybody else). The home page was requested with curl three times to warm every cache, then 20 to 25 times in a row; the figures are the median of the total time. Each variation was measured in three rounds that were interleaved (baseline, variation, next variation, …​) so that load on the machine, which was shared with other work, hit all of them alike. The chart shows the median of the three round medians and, as a thin line, the lowest and the highest round median.

Measured effect of PrestaShop performance settings on the warm home page: baseline 76 ms, OPcache disabled 202 ms, forced Smarty compilation 133 ms, SQL cache with APCu 39 ms; the Admin API is about 25 ms in production mode and 105 to 130 ms in dev mode
# a production-mode copy of the shop, sharing the database (names and ports are those of the test setup)
docker volume create pcfh-html
docker run --rm -v ec-ps-html:/from:ro -v pcfh-html:/to alpine sh -c 'cp -a /from/. /to/'
docker run -d --name pcfh-prod --network ec-ps --memory 512m -p 8090:80 -v pcfh-html:/var/www/html \
  -e PS_INSTALL_AUTO=0 -e DB_SERVER=ec-prestashop-db -e DB_NAME=prestashop -e DB_USER=ps -e DB_PASSWD="${DB_PASSWORD}" \
  -e PS_DEV_MODE=0 prestashop/prestashop:9.2.0

# the shop redirects to its canonical host, so send that name in the Host header
for i in 1 2 3; do curl -s -o /dev/null -H 'Host: localhost:8080' http://localhost:8090/; done     # warm up
for i in $(seq 1 25); do
  curl -s -o /dev/null -w '%{time_total}\n' -H 'Host: localhost:8080' http://localhost:8090/
done | sort -n | awk '{v[NR]=$1} END {printf "median %.0f ms, min %.0f ms\n", v[int(NR/2)+1]*1000, v[1]*1000}'

What the measurements say, with the caution that they come from one laptop, one container and one page:

Variation Home page, median Reading

Baseline: production mode, OPcache on, Smarty cache on, CCC on

76 ms (68 to 117)

325 SQL statements, 10.7 MB peak memory (from the profiler), 1110 PHP files included.

OPcache disabled

202 ms (191 to 247)

Two and a half to three times slower: every request compiles about 1100 files again. The one setting that must never be off.

Smarty compilation forced on every request (PS_SMARTY_FORCE_COMPILE=2)

133 ms (97 to 149)

About 60 ms of recompilation of the templates (the three rounds gave 21 to 73 ms more than the baseline). A development setting.

Smarty cache off (PS_SMARTY_CACHE=0)

81 ms (65 to 100)

Within the noise on time, but 357 instead of 332 queries per request: the six modules that cache their output ask the database again.

opcache.validate_timestamps=0

59 ms (59 to 99)

No difference beyond the noise on this machine (the file system is fast); it matters on network storage and when revalidate_freq is large.

SQL cache in APCu (Advanced Parameters > Performance > Caching)

39 ms (37 to 50)

28 statements instead of 325, and 7.5 MB instead of 10.7 MB peak memory: the largest gain of any switch of the Performance page.

Dev-mode container (reference)

63 ms (60 to 104)

The legacy front office is not slower in dev mode on a warm cache; what dev mode adds there is 63 HTML comments, 11 KB of page and a large log.

Admin API product list, limit=20

about 25 ms in production mode, 105 to 130 ms in dev mode

The Symfony kernels (back office, API) are where the debug mode costs: four to five times slower.

The conclusion to carry away: switch the production mode on first, then make sure OPcache works, then use the SQL cache if the catalogue is large, and treat everything else as a smaller gain that is worth measuring on your own pages.

Production mode

The debug mode (_PS_MODE_DEV_) is the biggest single difference between a development and a production shop, even when the front-office timings above hardly show it. Debug mode:

  • builds the Symfony containers with debugging (var/cache/dev instead of var/cache/prod), registers the profiler and the debug toolbar, and makes the Admin API four to five times slower (measured below) and the back office slower as well;

  • displays PHP errors on the page and sets error_reporting to E_ALL, with _PS_DEBUG_SQL_ true so that SQL errors are printed;

  • uses the Smarty class that wraps every template in <!-- begin ... --> comments (63 of them on the home page, 11 KB more HTML);

  • writes a much larger log: after an afternoon of testing, var/logs/dev-<date>.log was 124 MB and the production log of the same day 9.7 MB, both growing;

  • relaxes the HTTPS rule of the Admin API and exposes the experimental endpoints (see the Admin API page).

Switching it on and off

On a classic host, set the constant in config/defines_custom.inc.php (the back-office switch reads and writes that file first when it is readable, and falls back to config/defines.inc.php):

<?php
define('_PS_MODE_DEV_', false);
define('_PS_DEBUG_PROFILING_', false);

The same switches exist in Advanced Parameters > Performance > Debug mode: "Debug mode", an optional debug cookie name and value (so that only a browser that carries the cookie sees the debug mode, which is useful for diagnosing a production issue without exposing errors to every visitor), and "Debug profiler". "Disable all overrides" is in the same panel and turns off every class and controller override at once, which tells you in seconds whether an override is the cause of a problem. The page writes the constant into the defines file, which requires that file to be writable by the web user.

With the Docker image, the variable PS_DEV_MODE decides. The image default is 0; the table in the official Docker page that says 1 is wrong. The value is read with PHP’s (bool) getenv(...), so PS_DEV_MODE=false (a non-empty string) means true: write 0 or leave the variable out. The console command prestashop:debug does not understand this setup (it reported ON in a container started with PS_DEV_MODE=0 and, when asked to switch off, replaced the environment-driven line with a fixed constant), so on Docker change the variable of the container, see the CLI commands page.

bin/console about | tail -6        # shows "Debug mode" read from the defines files
curl -s -o /dev/null -w '%{size_download}\n' https://shop.example.test/       # smaller in production mode
curl -s https://shop.example.test/ | grep -c '<!-- begin '                   # 0 in production mode

The profiler as a measuring tool

The debug profiler (Debug profiler switch, or _PS_DEBUG_PROFILING_) appends a panel to every page with the load time, the query time, the number of queries, the peak memory, the number of included files, the list of queries with their durations, the tables that are queried most often, the duplicated queries and the ObjectModel instances. It is the tool for finding a module that costs hundreds of queries. On the test shop, a cold first request after clearing the cache showed 722 queries, 2061 ms, 2003 included files and 52.8 MB, and warm requests 325 queries, 58 ms and 1110 files: always measure warm. The panel adds about 3 MB to the page, so never leave it on for visitors; to profile one request on a production host, use a debug cookie and a short window.

Smarty

PrestaShop 9.2.0 uses Smarty 4.5.7 for the front office and for some back-office legacy pages. Advanced Parameters > Performance > Smarty has four settings, stored in ps_configuration:

Setting (key) Values Effect

Template compilation (PS_SMARTY_FORCE_COMPILE)

0 Never recompile, 1 Recompile if the files have been updated, 2 Force compilation

Smarty compiles a .tpl into PHP once and stores it in var/cache/<env>/smarty/compile. With 0 it never checks the template file; with 1 it compares the file date on each request (a cheap stat); with 2 it compiles every template on every request (about +60 ms on the home page). Use 1 while a theme is under development, 0 in production, and clear the cache after a deployment that changes templates. Never leave 2 on.

Cache (PS_SMARTY_CACHE)

on or off

Enables the caching of module output, not of whole pages. See below.

Multi-front optimizations (PS_SMARTY_LOCAL)

on or off

Uses a Smarty class that keeps the "flush markers" in the table ps_smarty_last_flush, so that several front servers that each have a local Smarty cache directory (not shared over NFS) clear their local copy when one of them flushes. Enable it only for a multi-server farm without shared storage; the official page advises against it for a single server.

Clear cache (PS_SMARTY_CLEAR_CACHE)

never or everytime

everytime (the default) lets a module invalidate its cached output when its data changes. In the module base class, never makes the clears that modules request during batch operations (module installs, bulk updates) do nothing, so the cache lives until it is flushed by hand; it is a niche setting for very busy shops and was not tested.

Which setting matters: the documentation contradicts itself

The page on configuring PrestaShop says the Smarty cache "should always be disabled" and shows $smarty->caching = false in config/smarty.config.inc.php; the optimisation page says to enable the Smarty cache. Both are true, because they talk about different things. The configuration file sets Smarty’s own caching to CACHING_OFF for every template, and that is not changed by any setting, so whole templates are not cached. What the Cache switch (PS_SMARTY_CACHE) controls is the helper Tools::enableCache(), which a module calls around the part of its code that it wants cached: if the switch is off, the helper does nothing, and the module renders its block again on each request; if it is on, the helper turns Smarty’s caching on for that template with a one-year lifetime and a cache id. On the test shop the modules that cache this way were ps_banner, ps_customeraccountlinks, ps_customtext, ps_imageslider, ps_linklist and ps_socialfollow: each has a directory under var/cache/<env>/smarty/cache.

Modules are free to keep their own caches too. The main menu of the home page is not cached in Smarty: ps_mainmenu stores the menu as a JSON file, var/cache/<env>/ps_mainmenu/<shop id>/MOD_BLOCKTOPMENU_MENU_JSON_<...>.json. That gave a clear demonstration in the test: a category name changed directly in the database was shown at once on the category page, but the menu of the home page kept the old name until the cache was cleared.

UPDATE ps_category_lang SET name = 'ClothesYY' WHERE id_category = 3 AND id_lang = 1;   -- run in the mysql client
curl -s https://shop.example.test/ | grep -m1 -o 'Clothes[A-Za-z]*'                        # Clothes: the menu file is still valid
bin/console cache:clear --env=prod
curl -s https://shop.example.test/ | grep -m1 -o 'Clothes[A-Za-z]*'                        # ClothesYY

bin/console cache:clear --env=prod removed the whole var/cache/prod directory, Smarty compile and cache directories and the module caches included; after it, the first request recompiled 48 templates and refilled six Smarty cache directories.

Two lessons for the people who manage a shop. Changes made through the back office clear the right caches (the module listens to the hooks of the data it shows), but changes made by SQL scripts, imports that bypass ObjectModel, or another application are invisible until the Smarty cache is cleared: add a cache clear to every such job. And a module that caches output without a good cache id (per language, per currency, per customer group, per shop) can show the wrong content to the wrong visitor; the official page notes the opposite risk, over-caching, which scatters hundreds of cache files over the disk.

The Smarty directories live in var/cache/<env>/smarty/{compile,cache}. On the test shop the compile directory held 80 files (1.4 MB) and the cache directory 6 files. PrestaShop 1.6 had a "Cache storage" setting (file system or MySQL) that older books and posts still describe; it does not exist in 9.2.0 (the form has the four fields above only), so the advice to choose "file system" is moot.

Combine, Compress and Cache (CCC)

CCC is a pair of switches under Advanced Parameters > Performance > CCC (Combine, Compress and Cache): "Smart cache for CSS" (PS_CSS_THEME_CACHE), "Smart cache for JavaScript" (PS_JS_THEME_CACHE) and "Apache optimization" (PS_HTACCESS_CACHE_CONTROL). A new 9.x install has the first two on. When they are on, PrestaShop concatenates the stylesheets and scripts that the theme and the modules register into one CSS file and the JavaScript into bottom-of-page files, names them with a hash of the content, and writes them to themes/<theme>/assets/cache. A change of content gives a new name, which is what lets the files be cached for a long time.

bin/console prestashop:config get PS_CSS_THEME_CACHE      # 1
curl -s https://shop.example.test/ | grep -oE '(href|src)="[^"]+assets/cache/[^"]+"'
# href="https://shop.example.test/themes/hummingbird/assets/cache/theme-986e33.css"
# src="https://shop.example.test/themes/hummingbird/assets/cache/bottom-30955c.js"

bin/console prestashop:config set PS_CSS_THEME_CACHE --value=0
bin/console prestashop:config set PS_JS_THEME_CACHE --value=0
# the same page now lists 10 stylesheets and 18 scripts

With CCC on, the home page of the test shop referenced 1 stylesheet and 2 scripts (3 tags); with it off, 10 stylesheets and 18 scripts (28 tags). The total size was the same order (about 800 KB uncompressed, about 190 KB after gzip), so the gain is the number of requests, which matters on HTTP/1.1 and on high-latency mobile connections and matters much less with HTTP/2. The core combines the files and minifies them with the matthiasmullie/minify library (CssMinifier and JsMinifier in classes/assets). Hummingbird’s own build (Node 24 and npm 11 for the sources of version 2.1.2) already minifies its theme.css (353 KB on a single line), so on this theme CCC mostly concatenates: the combined CSS of the test shop was 442 KB (84 KB with gzip). The useful pipeline is: build the theme assets minified, let CCC combine, let the web server compress, let a CDN cache.

"Apache optimization" rewrites the managed block of .htaccess with the expiry, compression and ETag rules (see the web server section). It has no effect on Nginx. Media servers (Advanced Parameters > Performance > Media servers, Media server #1..#3) are extra domain names from which the static files are requested, a trick to get more parallel connections on HTTP/1.1; on HTTP/2 and behind a CDN they add DNS lookups for nothing, so leave them empty and point a single CDN host name at the shop’s static paths instead (see the CDN section). The setting is only honoured when CCC is off for the file type (the front controller checks Tools::hasMediaServer() together with the CCC switch).

The SQL cache and cache backends

The panel Advanced Parameters > Performance > Caching turns on a cache for the results of SELECT queries. It is the most effective switch of the page and the most misunderstood.

Setting Meaning

Use cache (ps_cache_enable in app/config/parameters.php)

Turns the SQL cache on. Db::executeS() then looks a SELECT up in the cache by the hash of the query text before sending it to the server and stores the result afterwards.

Caching system (ps_caching)

CacheMemcache (PHP extension memcache), CacheMemcached (extension memcached), CacheApc (APCu, or the old APC) or CacheXcache (XCache, abandoned; do not use it). The form disables the choices whose PHP extension is not loaded.

Servers (table ps_memcached_servers)

For the two Memcached systems: IP, port and weight of each server, added in the same panel.

Facts about how it works, from the code of 9.2.0 and the tests:

  • The cache is keyed by the query text and invalidated by table: every INSERT, UPDATE, DELETE or other write that passes through Db::execute() removes the cached results that involve the written tables. Tables that change on almost every visit are excluded: cart, cart_cart_rule, cart_product, connections, connections_source, connections_page, customer, customer_group, customized_data, guest, pagenotfound, page_viewed, employee and log.

  • Writes that do not pass through PrestaShop’s Db class, such as an SQL script run in the mysql client, do not invalidate the cache. APCu is also private to the PHP process pool that owns it: the command line and each FPM pool have separate APCu memory (apc.enable_cli is Off), so a bin/console write does not clear the web pool’s APCu cache. Memcached is shared between processes and hosts and does not have this problem. In the two probes made on the test shop (a category name, a product price) the pages showed the new values immediately, because those reads do not go through the SQL cache, so the risk is real in principle but was not reproduced; after a bulk import, flush the cache (Cache::getInstance()->flush() from a script on the web side, or restart PHP-FPM or Apache for APCu).

  • The memory is limited (apc.shm_size is 32 MB by default); when the cache is full a store fails and PrestaShop flags that the table cache size needs adjusting. A catalogue with many thousands of products needs a larger apc.shm_size.

  • With MySQL 5.7 the server’s own query cache did the same job at the server and the official advice was "do not enable the SQL cache if MySQL is on the same machine". MySQL 8.0 removed the query cache (on the test server, query_cache_size does not exist), so that advice is obsolete for MySQL 8 and MariaDB deployments of the 9.x era: the SQL cache is again useful on a single server.

Enabling APCu

APCu is not in the official image. The steps below were run on the production-mode copy and the effect is the "SQL cache" row of the measurements (325 statements per home page became 28).

# 1. install and enable the extension (in a Dockerfile, once per image); apc.shm_size stayed at its default of 32M in the test
pecl install apcu
echo "extension=apcu.so" > /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini

# 2. enable the cache and choose APCu: back office (Performance > Caching > Use cache, APC) or edit the file
sed -i "s/'ps_caching' => '[A-Za-z]*'/'ps_caching' => 'CacheApc'/; s/'ps_cache_enable' => false/'ps_cache_enable' => true/" app/config/parameters.php

# 3. rebuild the containers and restart the web server so that every worker loads the extension
bin/console cache:clear --env=prod
apache2ctl -k graceful          # or: systemctl reload php8.5-fpm

A syntax detail: app/config/parameters.php is generated, not meant for hand edits, but the Performance page writes exactly these keys, so a scripted edit equals clicking. If the extension is missing when the cache is enabled the shop throws APC cache has been enabled, but the APC or APCu extension is not available on every request, so add the extension first.

Memcached works the same way with the other two systems and a server list. It was not run (no Memcached in the test environment). Use it when more than one front-end server must share the cache; place the Memcached server on the private network, never on localhost of a farm, and never expose its port to the internet.

Symfony cache, containers and logs

var/cache/<env>/<app> holds the compiled Symfony containers, routes, Twig templates and translations of the back office (admin) and the Admin API (admin-api); the front office has its own container files (FrontContainer*.php) in var/cache/<env>. The production directory is var/cache/prod, which also receives the cache of console commands run with --env=prod. On the test shop var/cache/dev was 119 MB and var/cache/prod 98 MB.

bin/console cache:clear --env=prod                      # about 7 to 9 seconds on the test shop
bin/console cache:clear --env=prod --app-id=admin-api   # only the API container
bin/console cache:warmup --env=prod                     # build the caches before the first visitor does
bin/console cache:pool:clear cache.app                  # the Symfony application cache pool (not run)

Rules: run the commands as the web user (a cache written by root stops the shop from rebuilding it); do not delete var/cache by hand while requests are served, because the shop recompiles on demand under load; keep var/cache on local disk (a network file system turns every rebuild into a bottleneck); and include a cache:clear in every deployment that touches code, services, routes or modules. Clearing the cache does not clear the Smarty compile cache of old template versions in all cases; the back-office Clear cache button and cache:clear empty it, a plain file copy of the new theme does not.

Rotate the logs. var/logs holds a log per day and environment and PrestaShop does not purge it: the test shop wrote 124 MB in one afternoon of dev-mode testing. Use logrotate on var/logs/*.log and keep an eye on the ps_log table (see the database section).

PHP

The official image runs PHP 8.5.12 as an Apache module (mod_php, MPM prefork); most production servers use PHP-FPM behind Apache (MPM event) or Nginx. In both cases the settings below are in php.ini or a file of conf.d.

OPcache

OPcache stores compiled PHP files in shared memory. Without it every request compiles about 1100 files for a front-office page (2000 on the first request after a cache clear): 202 ms instead of 76 ms in the test. It is enabled in the image. The values that the image uses, those that the official page recommends, and what the test showed:

Directive Image 9.2.0 Official page Notes

opcache.enable

1

1

Must be on. opcache.enable_cli is 0 in the image; keep it off for ordinary commands, so every console run compiles again.

opcache.memory_consumption

256 (MB)

256

After requests to three front pages, 1225 scripts used 44.9 MB and 211 MB was free; the back office and the API need more. Watch opcache_get_status() and raise it if free_memory approaches zero.

opcache.interned_strings_buffer

16 (MB)

32

12.4 MB of 16 was used after three front pages, so 16 is tight once modules and the back office are loaded: use 32.

opcache.max_accelerated_files

20000

16229

The shop with its modules is far below that; raise it only if num_cached_keys is near the limit (it reported 32531 keys as the internal limit, rounded up from 20000).

opcache.validate_timestamps

1 (with revalidate_freq=0, so a check on every request)

1 with revalidate_freq=10, or 0

With 0, PHP never looks at a file again: deploy by reloading PHP or calling opcache_reset(). No gain measurable on a local SSD; useful on network storage. A deployment script must then always reload PHP.

opcache.revalidate_path

0

0

Leave at 0.

opcache.max_wasted_percentage, opcache.max_file_size, opcache.enable_file_override

not set

10, 0, 0

Set only if you have measured a reason.

opcache.fast_shutdown

absent

1

The official page still lists it. It was removed in PHP 7.2 and does not exist in PHP 8.5; an ini file that sets it is ignored.

opcache.jit, opcache.jit_buffer_size

disable, 64M

not mentioned

The JIT with tracing and a 64 MB buffer gave no gain on the front office in the test (the work is I/O and template bound, not numeric). Leave it off.

<?php
// pcfh-opcache.php: drop in the web root of a test copy, call it once, delete it. Never leave it on a public shop.
header('Content-Type: application/json');
$s = opcache_get_status(false);
echo json_encode([
    'enabled' => $s['opcache_enabled'],
    'cached_scripts' => $s['opcache_statistics']['num_cached_scripts'],
    'hits' => $s['opcache_statistics']['hits'],
    'misses' => $s['opcache_statistics']['misses'],
    'used_mb' => round($s['memory_usage']['used_memory'] / 1048576, 1),
    'free_mb' => round($s['memory_usage']['free_memory'] / 1048576, 1),
    'interned_used_mb' => round($s['interned_strings_usage']['used_memory'] / 1048576, 1),
    'interned_free_mb' => round($s['interned_strings_usage']['free_memory'] / 1048576, 1),
]);
// {"enabled":true,"cached_scripts":1225,"hits":2254,"misses":1308,"used_mb":44.9,"free_mb":211.1,
//  "interned_used_mb":12.4,"interned_free_mb":3.6}

The status function describes the OPcache of the process that answers, so it has to be called through the web server; run on the command line it describes a different cache (or none). opcache.validate_timestamps=0 and every cache of var/cache mean that a new version of a module is invisible until the deployment reloads PHP and clears the caches: both belong in the deployment script.

Other PHP settings

  • realpath_cache_size=4096K and realpath_cache_ttl=600 (the image already sets them) avoid thousands of lstat calls. They are not compatible with open_basedir and are particularly valuable on network storage.

  • memory_limit is 512M in the image, max_execution_time is 0 (unlimited), upload_max_filesize and post_max_size 20M. The official page recommends memory_limit=512M, max_execution_time=300, post_max_size=22M and max_input_vars=20000 for products with many combinations. Set an execution time limit for web requests in production.

  • zend.assertions is 1 in the image, a development default: assertion calls are evaluated and can raise. Production should use -1 (compiled out).

  • display_errors=Off and expose_php=Off are already set in the image.

  • Run a supported PHP version: PrestaShop 9.2 supports PHP 8.1 to 8.5, the official image runs 8.5, and security support for PHP 8.2 ends on 2026-12-31 (8.1 already ended on 2025-12-31). A newer PHP is also faster.

  • Composer: composer dump-autoload --optimize --no-dev --classmap-authoritative builds a class map that avoids file-system lookups, as the official page suggests. An authoritative class map ignores classes that are not listed, so a module installed afterwards is unknown until the command is run again (in the module’s own folder for its vendor/): run it as the last step of a deployment.

PHP-FPM

For FPM, set pm.max_children high enough for the peak number of concurrent requests that you expect, and small enough that the workers fit in memory (measure it: the peak of the home page was 10.7 MB per request, the back office needs far more), pm = dynamic or ondemand according to traffic, and pm.max_requests to recycle workers. The official page gives the Apache event MPM example ServerLimit 16, MaxRequestWorkers 400. The image of the test shop uses mod_php with prefork, where each Apache child embeds PHP; do not copy FPM numbers into it.

Web server

Apache

The image has deflate, headers and rewrite loaded and no expires. The .htaccess that PrestaShop writes contains an <IfModule mod_expires.c> block with expiry times (images one month, CSS and JavaScript one week, fonts and SVG one year) and an <IfModule mod_deflate.c> block. Because <IfModule> ignores a block when the module is absent, the "Apache optimization" switch silently did nothing in the Docker image: the CSS file was answered with Last-Modified and no Cache-Control or Expires, so a browser could only revalidate it. Enabling the module fixed it:

# before: curl -I .../themes/hummingbird/assets/cache/theme-986e33.css  ->  Last-Modified only
a2enmod expires && apache2ctl -k graceful
curl -sI https://shop.example.test/themes/hummingbird/assets/cache/theme-986e33.css | grep -iE '^(cache-control|expires)'
# Cache-Control: max-age=604800
# Expires: Tue, 03 Nov 2026 16:00:37 GMT
curl -sI https://shop.example.test/img/logo.png | grep -iE '^(cache-control|expires)'
# Cache-Control: max-age=2592000     (one month)

Compression was on already: the home page was 133 KB identity and 15 KB with gzip, the combined CSS 440 KB and 84 KB. Other Apache settings: the official page offers Header set Cache-Control "max-age=2592000, public" for images, CSS and fonts as a mod_headers alternative to mod_expires, and FileETag none is already in the generated block. Standard Apache practice, not part of the PrestaShop page and not run here, adds HTTP/2 (Protocols h2 http/1.1, which needs the event or worker MPM with PHP-FPM, not prefork with mod_php), KeepAlive On, and moving the .htaccess rules into the virtual host with AllowOverride None, which saves a file look-up in every directory of every request. The HTML of the shop is sent with Cache-Control: no-store, no-cache, must-revalidate and an old Expires, which is right: do not try to cache the dynamic pages in a CDN without a very careful design (cart and customer cookies make a page personal).

Nginx

Nginx has no .htaccess, so the Apache optimization switch is irrelevant and the equivalents belong in the server block. The official page gives compression and expiry rules, adapted here (the type list is shortened and webp and avif were added to the expiry pattern); they were not run:

server {
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 1000;
    gzip_types application/javascript application/json application/manifest+json application/xml font/ttf font/otf
               image/svg+xml text/css text/javascript text/plain text/xml;

    location ~* \.(?:css|eot|gif|ico|jpe?g|otf|png|ttf|webp|avif|woff2?)$ {
        expires 1M;
        add_header Cache-Control "public";
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }
}

The official text warns about compression over TLS (BREACH). Mitigate it by not compressing responses that reflect a secret (the shop’s HTML includes CSRF tokens in forms), or accept it with the usual protections. Buffer the access log (access_log ... buffer=32k flush=5s) or switch it off for static files. With Nginx in front of PHP-FPM, the deny rules that .htaccess provides (see the Security page) must be reproduced in location blocks.

CDN, images and media

A CDN caches the static files at the edge and so removes most of the hits on the server. Rules that work for PrestaShop:

  • Cache only static paths (/themes/*/assets/, /modules/*/views/, /img/, /js/, /css/). CCC files carry a content hash in the name, so they can be cached for a year and need no purge when the shop changes them; product images change on upload and need a purge or a version string.

  • Never cache the HTML, the back office (/<admin folder>/), /admin-api/, /api/ or any URL with a cart or customer cookie.

  • Point the shop at the CDN host with the theme’s asset URLs or with a reverse proxy; PrestaShop’s Media servers settings are the legacy way (see the CCC section) and are not needed with a CDN that serves the whole domain.

  • After purging a CDN, expect a burst of requests to the origin; warm the main pages.

  • With several web nodes the images and the cache backend must be shared too; see Cloud: Shared Media and Files and Cloud: Cache, Sessions and Back Office.

  • A CDN does not replace compression and expiry at the origin: some clients bypass it.

Images are most of the bytes of a catalogue page. Design > Image Settings defines the sizes of each image type, the quality (PS_JPEG_QUALITY 90, PS_PNG_QUALITY 7, PS_WEBP_QUALITY 80, PS_AVIF_QUALITY 90 on the test shop) and the formats to generate (PS_IMAGE_FORMAT, a comma separated list that always starts with jpg and may add png, webp and avif). Each extra format multiplies the number of files; WebP is supported by the GD of the image, AVIF is not (the AVIF option needs a GD build with AVIF and the core checks for it), so on the test shop only JPEG and WebP could be produced. After changing the sizes or formats, regenerate the thumbnails (and do it off peak):

bin/console prestashop:config get PS_IMAGE_FORMAT
bin/console prestashop:thumbnails:regenerate products --env=prod       # all image types, all products; minutes for a large catalogue
bin/console prestashop:thumbnails:regenerate stores                    # ran in 1 or 2 seconds on the test shop

Upload product photos at the size the theme needs (for example 1000 x 1000 pixels for the coffee bags of Pocket Coffee), not at camera resolution; PrestaShop stores the original and derives the thumbnails from it. See the pages on media optimization for the general techniques (formats, responsive images, lazy loading) that apply to a PrestaShop theme.

The database

MySQL 8.4 is the tested database (MariaDB 10.2 and later are also supported). Things that matter, in order:

  • innodb_buffer_pool_size: the memory for table and index data. The test container uses the default of 128 MB, which suffices for a 12 MB shop; a real shop should set it to a value larger than the database size, as long as the server has the memory, because every read that misses the pool goes to disk.

  • The old query_cache_* settings that the official page still lists do not exist in MySQL 8 (the server variable query_cache_size is not defined on the 8.4.13 test server); skip them. table_open_cache (4000 here) and thread_cache_size (9 here) can be raised for busy shops, performance_schema off saves memory on small servers, and innodb_flush_log_at_trx_commit=1 (the default) is the safe value that costs write speed.

  • Turn on the slow query log (slow_query_log=1, long_query_time=0.5) for a day and read it with pt-query-digest or mysqldumpslow: the slow queries of a PrestaShop shop are almost always caused by one module.

  • Run ANALYZE TABLE after a bulk import so that the optimizer has current statistics.

  • ps_configuration is loaded on every request: the test shop has 530 rows; stray rows left by uninstalled modules add up in a shop that has lived for years. List them with SELECT name FROM ps_configuration ORDER BY name and remove what belongs to modules that no longer exist.

  • Read replicas are a legitimate scale-out for the read-heavy front office, but PrestaShop has no built-in read/write split; it needs a proxy or a custom Db implementation. A managed database on RDS or Aurora, and what the core can and cannot ask of it, is in Cloud: Managed Database (RDS and Aurora).

Tables that grow

The official page "Taking care of your PrestaShop" lists tables that grow with traffic: the logs (ps_log), the visit tracking (ps_connections, ps_connections_source, ps_connections_page, ps_guest, ps_page_viewed), the 404 page log (ps_pagenotfound), the search statistics (ps_statssearch), old abandoned carts (ps_cart and ps_cart_product) and, in some shops, ps_mail and the search index. On the test shop ps_connections, ps_connections_source and ps_guest already had about 700 rows after a day of tests, one row per visitor session, which at a few thousand visitors a day means millions of rows in a year. A purge such as this one (wrapped in a transaction and rolled back on the test shop to check the syntax; it removed nothing there because every row was recent) belongs to a scheduled job that runs off peak:

START TRANSACTION;
DELETE FROM ps_log WHERE date_add < DATE_SUB(NOW(), INTERVAL 90 DAY);
DELETE FROM ps_connections WHERE date_add < DATE_SUB(NOW(), INTERVAL 30 DAY);
DELETE FROM ps_connections_source WHERE date_add < DATE_SUB(NOW(), INTERVAL 30 DAY);
-- check the numbers before committing
SELECT ROW_COUNT();
ROLLBACK;   -- replace by COMMIT once the numbers are what you expect

Keep a backup first, delete in batches (LIMIT 10000 in a loop) on large tables so as not to hold locks for minutes, and check that no module’s reports depend on the rows you purge: the statistics modules read ps_connections and ps_guest, so their old figures will go with them. For ps_guest remove only guests without a customer account. The statistics modules themselves are a performance choice: each page view writes rows, and a shop that uses Google Analytics or another external tool can disable the internal ones.

Modules, themes and overrides

Most "PrestaShop is slow" cases end in a module. Method:

  1. Measure with the profiler on a warm page and note the number of queries and the load time.

  2. Disable the suspects one at a time (bin/console prestashop:module disable <name>, then cache:clear) and measure again, or switch on Disable all overrides to rule out the overrides.

  3. Look at the hooks the module uses: a module hooked on every page (displayHeader, displayFooter) that runs queries or loads scripts costs on every request; a module that only needs one page should register its hook for that page.

  4. Check the module’s output caching (see Smarty): a module that is expensive and caches nothing is a candidate for a cache with a good cache id, one that caches without an id is a candidate for a bug.

Hummingbird ships assets built with modern tooling (the page above requested 3 asset files with CCC), but any theme can be undone by dozens of stylesheets and scripts, large uncompressed images and a dozen third-party tags (analytics, chat, pixels). Measure with Lighthouse or WebPageTest on a mobile profile, not only with the server timings of this page. Overrides in override/ slow the class loader slightly and, more importantly, make updates risky; keep them rare.

The back office and the Admin API

The back office and the Admin API run on Symfony kernels, and they are what production mode helps most (the API list answered in about 25 ms instead of 105 to 130 ms). Other measures for the back office:

  • In Advanced Parameters > Performance > Optional features, "Combinations", "Features" and "Customer groups" can be switched off if the shop does not use them (not Pocket Coffee, which has 18 combinations per product, a feature for the origin and a coffee-club customer group); each removes queries and form fields. The page refuses to disable combinations while any product uses them.

  • Thumbnail generation and search indexing are long jobs: run them from the console (prestashop:thumbnails:regenerate, prestashop:search:index) at night, not in a browser request.

  • The product search index (ps_search_index, 789 rows here) must be kept current; prestashop:search:index -p <id> after a bulk import, and --full after a large change, instead of on each visitor’s first search.

  • The Admin API is stateless and cheap per request in production mode; the cost is in the CQRS handler. Page through large lists with limit and offset and ask for the narrowest scopes.

Legacy advice that no longer applies

Books and posts written for PrestaShop 1.5 and 1.6 describe settings that changed or disappeared. Checked against 9.2.0:

Older advice State in 9.2.0

Choose the Smarty "Cache storage": file system or MySQL

No such setting; the Smarty cache is always on the file system under var/cache/<env>/smarty.

Enable XCache or APC for the SQL cache

APCu works; XCache is still in the form but the extension is unmaintained and not a sensible choice for current PHP.

Turn the MySQL query cache on (query_cache_type)

Removed in MySQL 8.0; an unknown query_cache_* option in my.cnf stops the server from starting.

Minify CSS and JavaScript with CCC

CCC combines, minifies (matthiasmullie/minify) and caches; Hummingbird’s build already minifies its own files.

opcache.fast_shutdown=1

Removed in PHP 7.2.

Serve images from several media servers for parallel downloads

Counter-productive with HTTP/2 and a CDN.

Use a core override to speed up a class

Overrides are discouraged and risky on update; prefer a module or a service decoration.

Put the shop on a local XAMPP or MAMP stack for production

Never; use a real server stack with PHP-FPM or the official image.

Production checklist

Check How

Production mode on, profiler off, no debug cookie left

bin/console about; _PS_MODE_DEV_ false; in Docker PS_DEV_MODE=0.

OPcache on, enough memory, interned_strings_buffer 32

opcache_get_status() through the web server; no opcache.fast_shutdown in the ini files.

Smarty compile 0 (or 1 during a release), Smarty cache on, clear cache everytime

Performance > Smarty; bin/console prestashop:config get PS_SMARTY_FORCE_COMPILE.

CCC on for CSS and JavaScript

PS_CSS_THEME_CACHE=1, PS_JS_THEME_CACHE=1; the home page lists a few asset tags.

Expiry headers and compression on static files

curl -I of a CSS file shows Cache-Control and, with Accept-Encoding: gzip, Content-Encoding.

SQL cache with APCu or Memcached for a large catalogue

Performance > Caching; measured gain on the profiler’s query count.

innodb_buffer_pool_size larger than the database, slow query log reviewed

SHOW VARIABLES LIKE 'innodb_buffer_pool_size'.

Logs and tables that grow are purged or rotated

logrotate for var/logs; scheduled purge of ps_log, ps_connections and friends.

Deployment script clears caches and reloads PHP

bin/console cache:clear --env=prod, opcache_reset() or a PHP reload; Smarty cache cleared when templates changed.

CDN in front of static paths only

No HTML, back office or API cached; hashed CCC names cached for a long time.

A measurement before and after every change

The method of the first section, on the pages that matter to your visitors.

What was run and what was not

On a local PrestaShop 9.2.0 shop (official Docker image, MySQL 8.4, PHP 8.5.12, Apache 2.4.68 with mod_php and the prefork MPM) the following ran and produced the figures above: the home page timing in a development-mode container and in a production-mode copy of the shop on the same database, with interleaved rounds for the baseline, OPcache off, OPcache validate_timestamps=0, OPcache JIT, Smarty compilation modes 0, 1 and 2, Smarty cache on and off, CCC on and off (asset tag counts and bytes), the SQL cache with APCu (installed with pecl), the profiler output (query counts, memory, included files), OPcache status through the web server, the static headers with and without mod_expires, the compression sizes, the Admin API product list in both modes, the prestashop:debug behaviour in a copy, cache:clear timings, the size of logs and caches, the table purge inside a rolled-back transaction, and the configuration values read with prestashop:config. Every configuration value and file that was changed on the shared shop was restored (Smarty compile mode and cache, CCC switches, a category name and a product price touched for the stale-output tests); the production-mode copy and its volume were removed after the run.

Not run: Memcached and Memcache, XCache, Nginx and PHP-FPM (the image is Apache with mod_php), HTTP/2, a CDN, multiple front servers with Multi-front optimizations, a large catalogue (19 demo products and about 700 sessions only), AVIF generation (the image’s GD has no AVIF support), the full thumbnail regeneration of a catalogue, MariaDB, and any measurement of the back-office pages (they need a logged-in employee; only the Admin API list was timed). The timings are indicative: they come from a shared laptop and show orders of magnitude, not guarantees.