Memoization — caching function results with closures
Concept
Memoization is a performance optimisation technique that caches the result of a pure function for a given set of inputs. On subsequent calls with the same arguments, the cached result is returned without re-executing the function body. It is the simplest form of dynamic programming and is only correct for pure functions — those with no side effects whose output depends solely on the input.
PHP closures make memoization implementable without any framework: a wrapper closure captures a cache array by reference, serialises the argument list into a cache key, checks the cache, and falls through to the wrapped function only on a cache miss. The cache lives in the closure's captured environment and persists for the lifetime of the closure object.
The cache key must uniquely identify the argument combination. serialize(func_get_args()) works reliably for scalar arguments, arrays, and objects (provided they are serializable), but is slow for large arguments. For numeric or string keys, string concatenation or implode('|', $args) is faster. For object arguments, using spl_object_id() gives a stable key within a single request but will repeat across requests (objects are not unique across PHP processes).
Memory management is the primary risk. A memoized function that is called with many distinct argument combinations accumulates cache entries indefinitely. In a long-lived process (Laravel Octane, queue worker), this causes unbounded memory growth. Mitigations: use a fixed-size LRU cache, cap the entry count, or scope the cache to the request lifecycle.
Memoization pairs naturally with recursive functions, cutting exponential algorithms to polynomial. The canonical example is Fibonacci: naively recursive is O(2^n); memoized is O(n). PHP's call_stack depth limit (~1,000 default) prevents deep recursion regardless, so iterative memoized implementations are often preferable for large inputs.
Code Example
<?php
declare(strict_types=1);
/**
* Generic memoize wrapper.
* Only correct for pure functions (no side effects).
*/
function memoize(callable $fn): Closure
{
$cache = [];
return function () use ($fn, &$cache): mixed {
$args = func_get_args();
$key = serialize($args);
if (!array_key_exists($key, $cache)) {
$cache[$key] = $fn(...$args);
}
return $cache[$key];
};
}
// Expensive computation — simulate with sleep
$expensiveSquareRoot = memoize(function (float $n): float {
// In reality, this might be a DB query or HTTP call
usleep(100_000); // 100ms artificial delay
return sqrt($n);
});
$start = microtime(true);
$r1 = $expensiveSquareRoot(144.0); // cache miss — 100ms
$r2 = $expensiveSquareRoot(144.0); // cache hit — instant
$elapsed = microtime(true) - $start;
echo number_format($r1, 4) . PHP_EOL; // 12.0000
echo "Elapsed: " . round($elapsed, 3) . PHP_EOL; // ~0.1s (not 0.2s)
// Memoized Fibonacci — O(n) instead of O(2^n)
$fibonacci = memoize(function (int $n) use (&$fibonacci): int {
if ($n <= 1) return $n;
return $fibonacci($n - 1) + $fibonacci($n - 2);
});
echo $fibonacci(30) . PHP_EOL; // 832040 — instant with cache
// Bounded cache — prevents unbounded memory growth
function memoizeWithLimit(callable $fn, int $maxEntries = 100): Closure
{
$cache = [];
$keys = []; // ordered list for eviction
return function () use ($fn, &$cache, &$keys, $maxEntries): mixed {
$args = func_get_args();
$key = serialize($args);
if (array_key_exists($key, $cache)) {
return $cache[$key];
}
if (count($cache) >= $maxEntries) {
$evict = array_shift($keys); // remove oldest
unset($cache[$evict]);
}
$keys[] = $key;
$cache[$key] = $fn(...$args);
return $cache[$key];
};
}
$cachedSlug = memoizeWithLimit(
fn(string $s): string => strtolower(preg_replace('/\s+/', '-', trim($s))),
50
);
echo $cachedSlug('Hello World') . PHP_EOL; // hello-world
echo $cachedSlug('Hello World') . PHP_EOL; // cache hitInterview Q&A
Q: When is memoization incorrect to apply, and what are the failure modes in a PHP web application?
Memoization is only correct for pure functions — those whose output depends exclusively on their inputs and that produce no observable side effects. Applying it to impure functions causes stale-data bugs: memoizing a function that reads from the database will return the value from the first call even after the underlying row is updated, for the duration of the closure's lifetime. In a standard PHP-FPM request, that lifetime is just the request, so the risk is contained. In a queue worker or Laravel Octane application, the closure can persist across many requests. A memoized permission check or configuration lookup would return a cached value from the previous request's state, leading to authorisation bugs or configuration drift.
Q: How would you implement a request-scoped cache in Laravel without a third-party package?
Laravel's service container supports singleton bindings, which are created once per container instance. For a request-scoped memoization cache, bind a plain array or a simple Cache value object as a singleton: $this->app->singleton(MemoCache::class, fn() => new MemoCache()). Any service that depends on MemoCache will receive the same instance throughout the request and can use it to store computed values. Alternatively, use Laravel's own remember() in-memory array cache driver (driver array) — it behaves as an in-memory store that is discarded at the end of the process. For Octane compatibility, use scoped bindings ($this->app->scoped()) which are re-created per request even in a long-lived process.
Q: What is the performance cost of using serialize() as a cache key, and when should you use an alternative?
serialize() traverses the argument graph recursively, including all object properties and nested array values. For arguments that are large Eloquent models, deeply nested arrays, or graphs of objects, this can take milliseconds and allocate significant memory just to generate the key. Alternatives by use case: for scalar arguments (IDs, strings), use implode(':', func_get_args()); for single integer arguments, use the integer directly as an array key (PHP hash maps allow integer keys without hashing); for objects with a stable identity (Eloquent models with a primary key), use get_class($obj) . ':' . $obj->getKey(). Reserve serialize() for cases where argument complexity genuinely requires it and the function's own execution cost justifies the serialisation overhead.