The Cache Component

Cache pools, adapters, and the one get() call that returns a stored value or computes and stores it.

Syntax$cache->get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed

Some answers cost more than they are worth. A report that scans a million rows. A rate fetched over the network. A fragment of HTML that is the same for every visitor. A cache stores the answer under a key so the next request reads it back instead of working it out again.

Pools and adapters

Symfony organises caching into pools. A pool is a named store of items, and an item has a key, a value and an expiry. Behind each pool sits an adapter, the thing that holds the bytes. cache.adapter.filesystem writes files under var/cache. cache.adapter.apcu uses PHP shared memory, which is fast but local to one machine. cache.adapter.redis talks to a Redis server, so several machines read one cache. Changing adapter changes configuration, not your code.

Get, or compute

The cache contracts give you one method. get() takes a key and a callback. On a hit the stored value comes back and the callback never runs. On a miss the callback runs, its return value is stored, and you receive it. The callback is handed an ItemInterface, where expiresAfter() takes seconds or a DateInterval and expiresAt() takes a date. With no expiry the item stays until something removes it.

What trips people up

Keys are not free text. PSR-6 reserves the characters {}()/\@: and a key containing one of them throws. Values are serialized on the way in, so store plain data and treat what comes back as a copy.

Example

Example · php
<?php
// src/Service/CountryStatsProvider.php
declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class CountryStatsProvider
{
    // Injected through the constructor, typed on the interface.
    public function __construct(
        private CacheInterface $cache,
    ) {
    }

    public function medianIncome(string $code): int
    {
        return $this->cache->get(
            'country_median_income_' . $code,
            function (ItemInterface $item) use ($code): int {
                // Counted from the moment the value is written.
                $item->expiresAfter(3600);

                return $this->compute($code);
            },
        );
    }

    private function compute(string $code): int
    {
        // Stands in for the slow part: a heavy query or a remote call.
        return ['NL' => 38100, 'PT' => 17400][$code] ?? 0;
    }
}

// $provider->medianIncome('NL');
// Output: 38100
// A second call in the same hour returns 38100 without calling compute().

When to use it

  • Store the result of a slow aggregate query that feeds a dashboard panel.
  • Keep a third-party API response for an hour so one slow provider cannot slow every request.
  • Give long-lived reference data, such as country or currency lists, its own pool with a one-day lifetime.

More examples

The contracts shorthand and the PSR-6 longhand

The two methods do the same work. get() is shorthand for fetch the item, check isHit(), set the value, set the expiry, save, return. Read the long form once so you recognise getItem()/isHit()/save() in other people's code, then use get(), which also gives you stampede protection the long form does not.

Example · php
<?php
// src/Service/RateCard.php
declare(strict_types=1);

namespace App\Service;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class RateCard
{
    // Both interfaces resolve to cache.app, so this is one pool seen two ways.
    public function __construct(
        private CacheInterface $cache,
        private CacheItemPoolInterface $pool,
    ) {
    }

    public function shorthand(): string
    {
        return $this->cache->get('rate_card', function (ItemInterface $item): string {
            $item->expiresAfter(600);

            return $this->build();
        });
    }

    public function longhand(): string
    {
        $item = $this->pool->getItem('rate_card');

        if (!$item->isHit()) {
            $item->set($this->build());
            $item->expiresAfter(600);
            $this->pool->save($item);
        }

        return $item->get();
    }

    private function build(): string
    {
        return 'standard';
    }
}

// Output: 'standard' from either method

Declaring your own pool

Separate pools give you separate lifetimes and separate clears. default_lifetime applies to items the callback did not give an explicit expiry. The when@prod block swaps the adapter for Redis so several application containers share one cache; nothing in the PHP changes.

Example · yaml
# config/packages/cache.yaml
framework:
    cache:
        # The default application pool, injected as CacheInterface.
        app: cache.adapter.filesystem

        pools:
            # A pool of its own, so clearing it does not clear everything else.
            geo.cache:
                adapter: cache.adapter.filesystem
                default_lifetime: 3600

when@prod:
    framework:
        cache:
            default_redis_provider: '%env(REDIS_URL)%'
            app: cache.adapter.redis
            pools:
                geo.cache:
                    adapter: cache.adapter.redis
                    default_lifetime: 86400

Injecting a named pool with #[Target]

Without the attribute, a CacheInterface argument always receives cache.app. #[Target] names the pool you declared in cache.yaml. Reach for it when one kind of data deserves its own lifetime or its own adapter, or when you want to be able to clear it alone.

Example · php
<?php
// src/Service/GeoLookup.php
declare(strict_types=1);

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class GeoLookup
{
    public function __construct(
        #[Target('geo.cache')] private CacheInterface $cache,
    ) {
    }

    public function timezoneFor(string $city): string
    {
        return $this->cache->get('geo_tz_' . $city, function (ItemInterface $item) use ($city): string {
            $item->expiresAfter(86400);

            return $this->resolve($city);
        });
    }

    private function resolve(string $city): string
    {
        return ['Lisbon' => 'Europe/Lisbon'][$city] ?? 'UTC';
    }
}

// $lookup->timezoneFor('Lisbon');
// Output: 'Europe/Lisbon'

Discussion

  • Be the first to comment on this lesson.