Cache LLM API Responses in PHP: Keys, TTLs, and Safety

Cache LLM API responses in PHP with SHA-256 request keys, Symfony Cache, and TTLs to reduce repeat API calls while preventing stale answers and user data leaks.

PHP LLM API response caching workflow showing SHA-256 request keys, cache hits and misses, TTL expiration, and API calls.

To cache LLM API responses safely, hash the entire request, not just the user’s question: the model ID, system prompt, temperature, every message, and a version tag for your prompt template. Look that hash up before calling the API. On a hit, return the stored answer. On a miss, call the model, and store the result only if it finished normally. Give every entry a TTL, and never share a cached answer across users when the prompt contains anything personal. That is exact-match caching, and for FAQ-style traffic it means you pay once for an answer instead of on every visit.

The rest of this guide builds that cache in PHP, shows where it quietly goes wrong, and explains which requests should never touch it.

Why the Same Question Costs You Twice

An LLM API bills per request and per token, and it does not remember that it answered “What is your return policy?” four minutes ago for a different visitor. Every page load that triggers the call pays for a fresh generation and waits for it. Your server gains nothing from the repetition.

Traditional HTTP caching does not help here. The call is a POST, it carries an API key, and the response is not marked cacheable by any intermediary between you and the provider. If you want reuse, your application has to do it.

The payoff depends entirely on your traffic. A support widget where many visitors ask a handful of questions repeats constantly. A chat feature where each user holds a unique conversation barely repeats at all. Measure before you promise anyone a saving: log how often a request hash repeats over a week, and you will know whether a cache earns its complexity. For the wider picture of where money goes in an AI-assisted project, see what AI coding tools actually cost on a real project.

Three Kinds of Caching That Get Confused

Articles on this topic mix up three different mechanisms. They solve different problems, and only one of them is yours to build.

MechanismWhat is reusedWho runs itReturns a stored answer?
Exact-match response cacheThe full response for an identical requestYou, in your appYes
Similarity (“semantic”) cacheA response for a request that looks close enoughYou, with an embedding model and vector searchYes, possibly for a different question
Provider prompt cachingProcessing of a repeated prompt prefixThe provider, on their sideNo

Provider prompt caching is often mistaken for response caching. OpenAI’s prompt caching guide (read 2026-09-23) says the feature applies to input prefixes, not responses, and states plainly that “Prompt caching does not change how the model generates output tokens.” The model still writes a fresh answer every time. It reduces the cost of reading a long, repeated system prompt, and the guide documents a cached_tokens field in the usage details so you can confirm it is happening. Keep your static instructions at the start of the prompt and the variable part at the end, and you get that benefit for free. It stacks with the cache below; it does not replace it.

Similarity caching needs embeddings and a vector store, which is a separate architecture this guide does not cover. This article is about the exact-match cache: the one you can add to a PHP app this afternoon with a Composer package.

How to Cache LLM API Responses: Designing the Key

The cache key is where most LLM caches break. The tempting version is md5($userQuestion). It fails in at least four ways:

  • The model changes. Switch model IDs and you keep serving the old model’s answers.
  • The system prompt changes. You fix a wrong instruction, and the cache keeps serving answers written under the broken one.
  • Generation settings change. A response generated at a high temperature is not the answer you would get at zero.
  • Conversation history differs. The same final question after a different earlier exchange is a different request.

The fix is to hash everything you send, in a stable order. PHP’s hash() returns a lowercase hex string by default, and the php.net manual entry for hash() (read 2026-09-23) lists sha256 as a supported algorithm. A 64-character SHA-256 digest also contains no characters that cache backends reserve, which a raw question would.

Two details matter more than they look. First, sort associative array keys before encoding, because ['temperature' => 0, 'model' => 'x'] and ['model' => 'x', 'temperature' => 0] are the same request but produce different JSON. Leave lists (like messages) in order, since message order is meaningful. Second, add a prompt version string you control, such as faq-v3. Bump it whenever you edit the template, and every old entry becomes unreachable at once.

Never put the API key into the key material. It is not part of what determines the answer, and hashing it next to user data tends to end up in debug output. The rules in this API key security checklist apply to cache code as much as to the client itself.

A Working Cache in PHP With Symfony Cache

This targets PHP 8.2 or later, Composer 2, and symfony/cache 7.4. Install it with:

composer require symfony/cache:^7.4

The class wraps whatever function you already use to call your model provider. It does not care which provider that is. Your closure receives the request array and returns an array with at least text and complete, where complete is true only when the provider reported a normal finish (not a length cutoff, a content filter, or a tool call).

<?php
// PHP 8.2+, Composer 2, symfony/cache ^7.4
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class CachedLlmClient
{
    public function __construct(
        private CacheInterface $cache,
        private \Closure $callModel,
        private string $promptVersion,
        private int $ttlSeconds = 86400,
    ) {}
    /** @return array{cached: bool, response: array} */
    public function ask(array $request, ?string $scope = null): array
    {
        $key = $this->cacheKey($request, $scope);
        $hit = true;
        $response = $this->cache->get(
            $key,
            function (ItemInterface $item, bool &$save) use ($request, &$hit): array {
                $hit = false;
                $response = ($this->callModel)($request);
                // Never store a truncated, filtered or partial answer.
                if (($response['complete'] ?? false) !== true) {
                    $save = false;
                }
                $item->expiresAfter($this->ttlSeconds);
                return $response;
            }
        );
        return ['cached' => $hit, 'response' => $response];
    }
    public function cacheKey(array $request, ?string $scope): string
    {
        $material = [
            'prompt_version' => $this->promptVersion,
            'scope' => $scope ?? 'shared',
            'request' => self::sortKeys($request),
        ];
        return hash('sha256', json_encode(
            $material,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
        ));
    }
    private static function sortKeys(mixed $value): mixed
    {
        if (!is_array($value)) {
            return $value;
        }
        if (!array_is_list($value)) {
            ksort($value);
        }
        return array_map(self::sortKeys(...), $value);
    }
}
function normalizeQuestion(string $text): string
{
    return preg_replace('/\s+/u', ' ', trim($text)) ?? trim($text);
}
// Demo with a fake model so you can run this without an API key.
$calls = 0;
$fakeModel = function (array $request) use (&$calls): array {
    $calls++;
    $messages = $request['messages'];
    $last = $messages[array_key_last($messages)]['content'];
    return ['text' => 'Answer to: ' . $last, 'complete' => true];
};
$cache = new FilesystemAdapter('llm', 0, __DIR__ . '/var/cache');
$client = new CachedLlmClient($cache, $fakeModel, 'faq-v3');
$request = [
    'model' => 'your-model-id',
    'temperature' => 0,
    'messages' => [
        ['role' => 'system', 'content' => 'Answer store policy questions in two sentences.'],
        ['role' => 'user', 'content' => normalizeQuestion("  What is your   return policy? ")],
    ],
];
var_dump($client->ask($request)['cached']); // bool(false) on the first run
var_dump($client->ask($request)['cached']); // bool(true)
echo "Model calls: {$calls}\n";              // 1 on the first run

A few things this code does on purpose. The whitespace normalization runs on the text you actually send, so the key and the request always match. If the closure throws, the callback never returns, so nothing is stored and the exception reaches your error handling. And the bool &$save argument, which the Symfony Cache docs describe for discarding a computed value, keeps incomplete answers out of the cache while still returning them to the current user.

Symfony’s Cache Contracts also include stampede protection: by default, only one PHP process per host computes a given key at a time, so a burst of identical requests on a cold cache does not become a burst of identical API calls. Note the “per host”. If you run several web servers against a shared Redis pool, two servers can still miss at the same moment.

For production, swap FilesystemAdapter for a shared backend such as RedisAdapter. PHP-FPM runs several worker processes, and a cache that only one server can see gives each server its own cold start.

When a Cached Answer Is the Wrong Answer

A cache that serves a wrong or stale answer produces no error. The page loads, the text looks plausible, and nobody notices until a customer quotes an outdated policy back to you. That is the same class of problem described in debugging code that runs but gives the wrong result: no exception, just a wrong output. Build the defenses in from the start.

Scope anything personal. If the prompt includes the user’s name, order details, account data, or anything retrieved for that user, pass their user or tenant ID as $scope, or skip the cache entirely. Sharing a key across users is how one customer’s order summary appears on another customer’s screen. This is the most serious failure an LLM cache can have, and a shared 'shared' scope makes it easy.

Match TTL to how fast the truth changes. A cached explanation of a PHP error can live for days. A cached answer about stock levels or opening hours should be short, or should not be cached. If you are unsure, start short and lengthen it once you have seen the hit rate.

Know how to empty it. Bumping the prompt version invalidates everything built on the old template. For a single bad entry, compute its key with cacheKey() and call $cache->delete($key). Test that you can do this before you need to.

Log hits and misses. Record the key prefix and whether it was a hit, without the prompt text. Without that, you cannot prove the cache is working or tell whether it is serving something it should not. If the feature runs inside WordPress, those lines will land in the same place as everything else, so reading a WordPress debug log to name the guilty code applies directly.

Requests You Should Not Cache

Request typeCache it?Why
Fixed FAQ answer, temperature 0, no user dataYes, long TTLSame input, same useful output
Classifying or summarizing a fixed documentYes, keyed on document contentThe document is the input
Answer built from the user’s own account dataOnly with a per-user scopeCross-user leakage risk
Creative output where variety is the pointNoUsers expect a different result
Multi-turn chatRarelyFull history is in the key, so repeats are uncommon
Agent runs that call tools or change stateNoReplaying the output skips the side effects

Streaming responses can be cached, but only the finished text. Buffer the chunks as they arrive, store the assembled answer once the stream ends normally, and on a hit either send the stored text at once or replay it in chunks. Never cache a stream the user abandoned partway through.

What This Cache Cannot Do

Exact-match caching only helps when the request is truly identical. “What’s your return policy?” and “How do returns work?” are two keys and two paid calls. Normalizing whitespace catches trivial variation; lowercasing everything catches a little more but can change meaning in code or product names, so decide that per feature. Closing the gap between differently worded questions needs similarity matching, and that trades a cheaper bill for the risk of answering a question the user did not ask.

It also cannot make a wrong answer right. If the model gave a poor response and you cached it for a day, you now serve that poor response for a day. Review what your high-traffic keys actually return before you give them long TTLs.

FAQ

Does caching LLM responses break my provider’s terms of service?

That depends on the provider and your plan, and terms change. Read your model provider’s current usage policies for anything about storing or reusing outputs before you ship a cache, and treat their document as the authority rather than any blog post, this one included.

Should I cache when temperature is above zero?

Only if you are comfortable freezing one sample as the answer. At higher temperatures the model is meant to vary, so a cache turns a creative feature into a fixed one. For factual or classification tasks, set temperature to 0 and cache; for creative tasks, skip it.

Is provider prompt caching enough on its own?

It lowers the cost of repeated prompt prefixes, but the model still generates a new answer each time, so you still wait for and pay for the output. If the full question repeats, your own response cache is what avoids the call.

Where should the cache live in a PHP app?

In a shared backend such as Redis when you have more than one server or worker pool, and in the filesystem only for a single-server site or local development. Keep the caching logic in its own class, as above, so switching backends is a one-line change.

Written by

Shah Alom

Shah Alom is the founder and writer behind Ebuhu, where he covers PHP, WordPress development, plugin and theme development, debugging, practical programming techniques, and AI-assisted coding. Drawing on hands-on web development experience, he focuses on clear, practical guidance that helps developers understand how things work, avoid common mistakes, and write more reliable code.

Leave a Reply

Your email address will not be published. Required fields are marked *