rikudou/memoize-bundle
A Symfony bundle for memoizing expensive method calls. Add attributes to service methods to cache results and avoid repeated computation, improving performance with minimal code changes.
First, a reality check: rikudou/memoize-bundle is a Symfony bundle, not a Laravel package. It registers a Bundle subclass, ships a YAML/PHP config tree, and hooks into Symfony's DI compiler passes to decorate services. You cannot composer require it into a Laravel app and expect a service provider to appear. What a Laravel developer can actually take from it is (a) the pattern — attribute-driven method memoization backed by a PSR-6/PSR-16 pool — and (b) the source itself, which is small enough to read end-to-end and port.
As of v1.1.0 the bundle additionally supports Symfony 7 and a cache key specifier — the ability to control how the cache key is derived from method arguments. That second feature is the one most worth stealing for a Laravel port, because it directly addresses the "arguments must be serializable and stable" trap (see Gotchas).
Look here first (in the repo):
| Where | Why |
|---|---|
README.md |
The only real documentation; shows the #[Memoize] attribute, the cache key specifier, and config stub |
src/Attribute/ |
The attribute class and its options (cache pool, key strategy / specifier, TTL) |
src/DependencyInjection/ |
The compiler pass that decides which services get proxied |
composer.json |
Required Symfony version (now includes 7), PHP version (attributes ⇒ PHP 8+), cache contracts |
Minimal Symfony usage (what the README demonstrates, so you can judge it):
composer require rikudou/memoize-bundle
// config/bundles.php
return [
// ...
Rikudou\MemoizeBundle\RikudouMemoizeBundle::class => ['all' => true],
];
use Rikudou\MemoizeBundle\Attribute\Memoize;
class ExchangeRateFetcher
{
public function __construct(private HttpClientInterface $http) {}
#[Memoize]
public function rateFor(string $currency, string $base = 'EUR'): float
{
return $this->http->get("https://api.example.com/{$base}/{$currency}")->toArray()['rate'];
}
}
The first real use case is exactly this: a container-managed service that makes an expensive external call or does heavy computation, where the same arguments recur within a request or across a short TTL. Memoization turns repeated calls into cache hits without you hand-writing Cache::remember everywhere.
Laravel translation of that first use case — pick whichever is closer to your app's semantics:
// Per-request memoization (PHP 8.3 / Laravel 11)
public function rateFor(string $currency, string $base = 'EUR'): float
{
return once(fn () => $this->http->get(...)->toArray()['rate']);
}
// Cross-request memoization
use Illuminate\Support\Facades\Cache;
public function rateFor(string $currency, string $base = 'EUR'): float
{
return Cache::remember("fx:{$base}:{$currency}", now()->addMinutes(10), fn () =>
$this->http->get(...)->toArray()['rate']
);
}
1. Decorator-over-repository. The canonical pattern in Symfony is: keep the service interface, let the compiler pass wrap the implementation, and inject the interface everywhere. In Laravel you do this explicitly with container bindings instead:
// AppServiceProvider::register()
$this->app->bind(RateProvider::class, MemoizedRateProvider::class);
class MemoizedRateProvider implements RateProvider
{
public function __construct(private RateProvider $inner) {}
public function rateFor(string $currency, string $base = 'EUR'): float
{
return Cache::remember(
'rate:'.md5(serialize([$currency, $base])),
now()->addMinutes(10),
fn () => $this->inner->rateFor($currency, $base),
);
}
}
This is the closest 1:1 port of what the bundle does automatically — and it has the advantage that invalidation and key composition are visible.
1b. Porting the cache key specifier (new in v1.1.0). The bundle now lets you declare how the key is built rather than relying on a blanket serialize($args). Mirror that in Laravel by making the key strategy a first-class concern instead of an inline md5(serialize(...)):
interface KeySpecifier
{
/** @param array<string,mixed> $args */
public function key(string $class, string $method, array $args): string;
}
final class ScalarKeySpecifier implements KeySpecifier
{
public function key(string $class, string $method, array $args): string
{
return 'memo:'.sha1($class.'::'.$method.'|'.json_encode($args));
}
}
// For methods whose args include models, key off IDs only:
final class EloquentKeySpecifier implements KeySpecifier
{
public function key(string $class, string $method, array $args): string
{
$normalised = array_map(
fn ($a) => $a instanceof \Illuminate\Database\Eloquent\Model ? [$a::class, $a->getKey()] : $a,
$args,
);
return 'memo:'.sha1($class.'::'.$method.'|'.json_encode($normalised));
}
}
This split (strategy object vs. hard-coded serialize) is exactly the extension point the new release formalises — adopt it in your own port even if you never touch the bundle.
2. Attribute + resolver (if you want the ergonomics). You can build a tiny memoizing resolver in Laravel that reads your own attribute off the method and caches the result, optionally delegating key composition to a specifier:
#[Attribute(Attribute::TARGET_METHOD)]
final class Memoize
{
public function __construct(
public int $ttl = 600,
public ?string $keySpecifier = null,
) {}
}
function memoize(object $target, string $method, array $args): mixed
{
$ref = new ReflectionMethod($target, $method);
$attr = $ref->getAttributes(Memoize::class)[0]?->newInstance();
if ($attr === null) {
return $target->$method(...$args);
}
$key = $attr->keySpecifier
? app($attr->keySpecifier)->key($target::class, $method, $args)
: 'memo:'.$target::class.':'.$method.':'.md5(serialize($args));
return $attr->ttl > 0
? Cache::remember($key, $attr->ttl, fn () => $target->$method(...$args))
: once(fn () => $target->$method(...$args));
}
Wrap it in a __call proxy or a small higher-order helper for day-to-day use. This mirrors the v1.1.0 attribute shape (attribute carries intent; a specifier carries key policy).
3. Workflow tips. Memoization belongs at the edge of your domain — external APIs, config lookups, expensive DTO/query building — not around Eloquent persistence, where it fights with transactional state. Keep the memoized method pure with respect to its arguments: same args ⇒ same result for the TTL. Anything that reads now(), auth()->id(), or request context inside the method should have that context promoted into the arguments (or into a custom key specifier) so it lands in the key.
4. Integration with queues and console commands. A per-request cache (once(), or the bundle's request-scoped pool) is thrown away at the end of a job; a shared pool (cache.app, Redis) survives. Decide per call site: memoization for dedupe within a single job is safe under concurrency, shared-pool memoization is not a lock — use Cache::lock if you actually need to prevent duplicate work. With a custom key specifier you can namespace job-scoped keys by job ID, which reduces cross-job bleed.
5. Testing. In Symfony, the bundle's own docs suggest pointing the cache pool at an array/in-memory adapter in the test env. In Laravel, set CACHE_STORE=array in phpunit.xml — Laravel already does this by default, which makes memoized decorators trivially assertable. If you add a key specifier, unit-test it directly: given args, assert the produced key is stable and collision-free.
The bundle is not installable into Laravel as-is. RikudouMemoizeBundle extends Bundle; Laravel's PackageManifest won't discover it and there is no Illuminate\Support\ServiceProvider. If you composer require it in a Laravel app you'll pull Symfony HTTP Kernel/Config/DependencyInjection transitively and get nothing in return. Treat it as a reference implementation, not a dependency. (v1.1.0's Symfony 7 support only widens the Symfony-version matrix — it changes nothing for Laravel.)
The cache key specifier is the headline feature to learn from. v1.1.0 lets you override how arguments become a cache key. This is the correct fix for the two biggest memoization failures: (a) args that aren't safely serializable, and (b) keys that silently change when an object's state or __toString changes. In a Laravel port, prefer a small specifier class over md5(serialize($args)) the moment your args include models, DTOs, or anything with volatile state.
Self-invocation bypasses the proxy. This is the classic AOP trap the bundle inherits: if methodA() calls $this->methodB() where methodB is #[Memoize]-annotated, the internal call does not pass through the generated proxy, so methodB is not memoized. The same issue bites Laravel decorators — you must inject the decorated service, not call $this directly.
Only container-managed services get memoized. new ExchangeRateFetcher() produces an unproxied instance. In Laravel that means: decorate via the container, and never new a service you expect to be cached.
Arguments must be serializable and stable — unless you supply a specifier. Symfony's cache contracts will serialize arguments into the key (or serialize the whole result). Closures, resources, Eloquent models with volatile attributes, and objects whose __toString/jsonSerialize includes timestamps all produce cache misses or collisions. Prefer scalar arguments, or use the v1.1.0 key specifier (or your own KeySpecifier) to build the key from IDs.
No automatic deploy invalidation. Change the body of a memoized method and the old results remain in the shared pool until TTL expires. The bundle gives you no version namespace. Mitigate by prefixing keys with a version (memo:v2:...) — trivially done via a key specifier — or by clearing the pool in your deploy script. In Laravel: php artisan cache:clear, and per-store Cache::store('redis')->clear().
TTL lives on the pool, not (necessarily) the attribute. The bundle delegates expiry to the configured Symfony cache pool; check the attribute class source before assuming you can set a per-method TTL. If you need per-method expiry and the attribute doesn't expose it, you're back to a hand-rolled decorator.
Final classes, private methods, and readonly services can break proxying. Anything the DI layer can't subclass or wrap (PHP final, private constructors, readonly promoted properties in some Symfony versions) will silently fail to register or throw during container compilation. Run php bin/console debug:container Your\Service (or php artisan about/tinker + app()->bound()) to confirm you're getting the decorated definition, not the raw one.
Debugging checklist: in Symfony, debug:container --show-arguments <service> tells you whether the proxy is wired; cache:pool:clear cache.app proves whether stale entries are the problem; and the profiler's cache panel shows hit/miss per request. If a v1.1.0 key specifier is misbehaving, dump the key directly and diff it between calls. In a Laravel port, Cache::getStore() plus a Redis KEYS memo:* sanity check does the same job.
Maturity signal. Small project, single maintainer — but v1.1.0 (Symfony 7 support + key specifier) shows active maintenance rather than abandonment. It's MIT and the source is short, so vendoring or copying the attribute + compiler-pass logic is entirely reasonable — but don't make it a critical-path dependency expecting long-term support, and pin a version (^1.1) if you do use it in a Symfony codebase.
Extension points worth stealing. The cleanest idea in the package — now more explicit in v1.1.0 — is separating the attribute (declarative intent) from the pool (policy) and the key specifier (identity). Reuse that three-way split in Laravel: one attribute for "memoize this", a container-resolved driver for "where and how long", and a specifier for "what makes this result unique." It keeps controllers and services free of cache-key string math, which is where most Cache::remember implementations rot.
How can I help you explore Laravel packages today?