Skyline

Full-Page Caching in Laravel: Large Redis Entries, predis vs phpredis, and Compression

· 9 min read · Boring Observability

Verified against Laravel 13.33 · phpredis 6.3 · predis 3.6

A full-page cache stores the whole rendered response, so an entry is as large as the page it holds. Our Redis monitor showed response-cache entries of about 25MB, and ordinary listing pages were taking 1.6 to 2.8MB each. The hit rate looked fine. Redis memory and the size of every read did not.

Most of that size goes away with compression, and where it happens depends on the Redis client. phpredis can compress every value on a connection with one config option. predis can't, so on predis the compression has to happen in the cache serializer. The examples use spatie/laravel-responsecache, the open-source full-page cache for Laravel that Spatie maintains, but the Redis side applies to any cache that stores rendered HTML.

Key takeaways#

  • Rendered HTML compresses well. Our listing pages went from 2.71MB to 0.08MB with zlib, and a 1.47MB article page went to 169KB with zstd.
  • predis has no compression option. phpredis compresses values with lzf, lz4 or zstd when you set compression on the connection, provided the extension was built with that algorithm.
  • Give the response cache its own connection. On a compressed connection, Cache::increment() silently returns false unless pack_ignore_numbers is set.
  • Enabling compression needs no flush, but disabling it does. A compressed connection reads old plain entries, and an uncompressed client can't read compressed ones.
  • On predis, compress in the serializer. gzcompress() reaches the same size as zstd but costs about seven times as much per write.

Why a full-page cache entry gets big#

responsecache serializes the response to JSON with its status, headers and body, and stores that string. The body is everything the browser receives, including inline SVG icons, JSON-LD blocks, inline scripts and a Livewire snapshot for every component on the page. A listing page with a card per result, each carrying its images, prices and links, reaches megabytes quickly. JSON then escapes every quote and slash, so the stored string is larger than the HTML.

The number of entries multiplies that. Our hasher keys each page by locale, currency and user, so one URL can hold dozens of guest entries, and every query-string variant gets its own set. The 25MB entries came from a URL that no page links to, which was still rendered and cached for every visitor who reached it.

redis-cli --memkeys samples the keyspace and reports the largest key of each type by memory, and MEMORY USAGE gives the size of a single key:

redis-cli -n 1 --memkeys
redis-cli -n 1 MEMORY USAGE "laravel_cache:9f2c...-eur-en-guest"

Redis keeps every entry in memory, so a few hundred page variants can push the instance into maxmemory eviction. If the cache store is shared with the rest of the app, the keys it evicts can be sessions, locks or anything else. Each cache hit also moves the full value from Redis to PHP and decodes it there, which on a busy page is most of the traffic between the two.

predis and phpredis#

Laravel talks to Redis through one of two clients, chosen by database.redis.client (the REDIS_CLIENT variable). The framework defaults to phpredis, but many apps run predis because it installs with Composer and needs no extension, which is the only option on hosts that don't allow extensions.

predis phpredis
What it is Pure PHP library, composer require predis/predis C extension, pecl install redis or a distro package
Protocol parsing In PHP In C
Value compression None lzf, lz4, zstd, set per connection
Value serializers None, Laravel's cache calls serialize() php, igbinary, msgpack, JSON
Deploy cost Nothing The extension in every image, CI runner and dev machine

For small values the two perform close enough that the choice rarely matters. For values the size of a page, phpredis's built-in compression is the main reason to prefer it.

What compression costs#

These are the stored size and the time per put and get for a 1.47MB response-cache payload written through Laravel's RedisStore:

Client and compression Stored Put Get
predis1,500KB1.03ms0.55ms
predis + gzcompress() level 6172KB17.1ms2.15ms
phpredis1,500KB0.40ms0.51ms
phpredis lzf334KB2.29ms2.46ms
phpredis lz4277KB1.52ms0.52ms
phpredis zstd level 3169KB2.42ms0.81ms
phpredis zstd level 9143KB11.6ms0.70ms

zstd at its default level stores the page in about a ninth of the space. It adds 2ms to a write and about 0.3ms to a read. Writes happen only on a cache miss, which already pays for a full render, so the write cost barely registers. Reads happen on every hit, and zstd and lz4 both decompress quickly enough that a hit stays under a millisecond. lz4 suits you better if write time matters more than memory. Level 9 saves another 15% for five times the write cost, which we didn't think was worth it.

The timings assume Redis close to PHP. With a managed Redis in another zone, or any link where bandwidth is the limit, the uncompressed reads slow down in proportion to their size, and the compressed ones barely change. The ratio depends on the markup too. Our own listing pages repeat the same card markup many times, and they compressed about 33 times with zlib, where the article page above compressed about 9 times.

Enabling phpredis with compression#

The extension compresses only with algorithms it was compiled with. The PECL build asks about lzf, zstd and lz4 at configure time and skips any whose library headers are missing. A Dockerfile that gets all three:

RUN apt-get update && apt-get install -y liblz4-dev libzstd-dev \
    && pecl install --configureoptions \
        'enable-redis-igbinary="no" enable-redis-msgpack="no" enable-redis-lzf="yes" enable-redis-zstd="yes" enable-redis-lz4="yes"' \
        redis \
    && docker-php-ext-enable redis

A distro package or a hosting panel's build may leave some of them out, so check before relying on it:

$ php --ri redis | grep compression
Available compression => lzf, zstd, lz4

If the line is missing or doesn't list the algorithm you configure, rebuild the extension first. Then switch the client. database.redis.client applies to every Redis connection in the app, so this moves the default connection, the cache, the queue and Horizon to phpredis together:

REDIS_CLIENT=phpredis

This step needs no flush. With no serializer or compression options set, both clients store and read the same bytes.

Compression is set per connection, and we keep it on the response cache only. Add a connection and a cache store for it:

// config/database.php, under 'redis'
'responses' => [
    'url' => env('REDIS_URL'),
    'host' => env('REDIS_HOST', '127.0.0.1'),
    'username' => env('REDIS_USERNAME'),
    'password' => env('REDIS_PASSWORD'),
    'port' => env('REDIS_PORT', '6379'),
    'database' => env('REDIS_RESPONSE_CACHE_DB', '2'),
    'compression' => Redis::COMPRESSION_ZSTD,
    'compression_level' => 3,
    'pack_ignore_numbers' => true,
],

// config/cache.php, under 'stores'
'responses' => [
    'driver' => 'redis',
    'connection' => 'responses',
    'lock_connection' => 'responses',
],
RESPONSE_CACHE_DRIVER=responses

Redis::COMPRESSION_ZSTD is a constant of the extension's class, so every environment that loads the config needs phpredis, including CI and config:cache in a build step.

A separate store also limits what a clear removes. With no responsecache.cache.tag set, ResponseCache::clear() flushes the whole store it's pointed at, and on a store of its own that means only responses.

Counters on a compressed connection#

phpredis compresses every value it writes through that connection, integers included. Redis's INCRBY then finds a compressed blob where it expects a number and refuses it, and phpredis reports that as false with no exception:

Cache::store('responses')->put('n', 5, 60);
Cache::store('responses')->increment('n');   // false, and 'n' is still 5

Laravel's RateLimiter works around this by turning compression off around its own writes, so throttle keeps counting. Your own Cache::add() and Cache::increment() pairs don't get that treatment. The pack_ignore_numbers option in the config above tells phpredis to store numbers as plain values, and with it the same two lines return 6. A connection used only by the response cache has no counters on it in the first place.

Deploying and rolling back#

phpredis with compression on still reads values written without it, with lzf, lz4 and zstd alike. The deploy that turns compression on therefore needs no flush. Old entries are served until they expire, and new ones are written compressed.

Going back is different. predis, or phpredis without the compression option, gets the compressed bytes back as they are, and RedisStore's unserialize() fails on them. If you roll back the config, clear the response cache store as part of the rollback:

php artisan responsecache:clear

The same applies to anything else reading that Redis database directly, such as a script or a second app on predis.

If you have to stay on predis#

Without the extension, compress before the value reaches the client. responsecache lets you replace its serializer, and a subclass of its JsonSerializer can zlib the JSON using only ext-zlib, which nearly every PHP build has:

namespace App\ResponseCache;

use Spatie\ResponseCache\Exceptions\CouldNotUnserialize;
use Spatie\ResponseCache\Serializers\JsonSerializer;
use Symfony\Component\HttpFoundation\Response;

class CompressedJsonSerializer extends JsonSerializer
{
    public function serialize(Response $response): string
    {
        return gzcompress(parent::serialize($response), 6);
    }

    public function unserialize(string $serializedResponse): Response
    {
        // Entries cached before compression are plain JSON, and a zlib stream never starts with '{'
        if (str_starts_with($serializedResponse, '{')) {
            return parent::unserialize($serializedResponse);
        }

        $json = @gzuncompress($serializedResponse);
        if ($json === false) {
            throw new CouldNotUnserialize('Could not gzuncompress cached response');
        }

        return parent::unserialize($json);
    }
}
// config/responsecache.php
'serializer' => App\ResponseCache\CompressedJsonSerializer::class,

This took our listing pages from 2.71MB to 0.08MB. The value is already a binary string when Laravel receives it, so it works with predis and phpredis alike, and the fallback for JSON entries means the deploy needs no flush. A corrupt entry throws CouldNotUnserialize, which the middleware treats as a miss, and the next render overwrites it.

zlib is slower than phpredis's zstd. In the table above, gzcompress() at level 6 stores the page at about the same size as zstd but takes 17ms per write against 2.4ms, and 2.2ms per read against 0.8ms. The write cost falls only on misses, which makes it acceptable when you can't install an extension. If you can install ext-zstd to use in the serializer, installing phpredis takes the same effort and needs no custom class.

Compression makes each entry smaller but doesn't change how many of them get rendered. If the cache is missing more than it should, the responsecache hit-rate post goes through the click ids and key mismatches that caused most of our misses.

Frequently asked questions

Does predis support compression?

No. predis sends values to Redis as it gets them. To compress on predis, compress before the value reaches the client, for example in a custom responsecache serializer that calls gzcompress(). phpredis has compression built in: set compression to Redis::COMPRESSION_ZSTD, COMPRESSION_LZ4 or COMPRESSION_LZF on the connection in config/database.php.

How do I check which compression algorithms phpredis supports?

Run php --ri redis and look for the "Available compression" line. The extension only compresses with algorithms it was compiled with, so a PECL build needs the liblz4 and libzstd headers installed and the enable-redis-lz4 and enable-redis-zstd configure options set.

Why does Cache::increment() return false with phpredis compression?

phpredis compresses every value it writes through the connection, integers included, so INCRBY finds a compressed blob instead of a number and fails, which phpredis returns as false. Set pack_ignore_numbers to true on the connection so numbers are stored plain, or keep counters off the compressed connection. Laravel's RateLimiter turns compression off around its own writes and is not affected.

Do I need to flush Redis when enabling phpredis compression?

No. A compressed phpredis connection still reads values written without compression, so old entries are served until they expire. Disabling compression is different: predis or an uncompressed phpredis connection cannot read compressed values, so clear the cache store when rolling back.