emag-tech-labs/annotation-cache-bundle
Symfony bundle that adds annotation or PHP 8 attribute based caching to service method responses. It scans services, generates proxy classes, and stores results in any PSR-6 cache implementation such as Redis, Memcache, or a simple array.
First, calibrate expectations: this is a Symfony DI bundle, not a Laravel package. It works by running a compiler pass over the container, finding services whose methods carry the Cache annotation/attribute, and generating an ocramius/proxy-manager subclass that overrides those methods with getCached(). There is no Laravel service-provider equivalent because Laravel's container does not do compile-time method proxying. So there are two paths:
Cache::remember() (pattern below). Installing it in Laravel means pulling in symfony/dependency-injection, a compiler pass, and proxy generation — not worth it for 19 stars and a 2023 last release.If you are on Symfony:
composer require emag-tech-labs/annotation-cache-bundle
Flex registers it; otherwise add eMAG\Bundle\AnnotationCacheBundle\AnnotationCacheBundle to config/bundles.php.
Then look at these three files before writing code — they are the real documentation:
vendor/emag-tech-labs/annotation-cache-bundle/src/Annotation/Cache.php — the actual option names (cache pool id, key, expiresAfter, etc.). Do not trust blog posts; option names drifted between releases.bin/console config:dump-reference annotation_cache — tells you exactly which config keys exist in your pinned version (the scanned service paths/namespaces live here; nothing is cached unless the service is in scope).var/cache/{env}/ after one warmup — this is where "why isn't it caching?" gets answered.First use case: a service method that hits a slow, idempotent external source.
use eMAG\Bundle\AnnotationCacheBundle\Annotation\Cache;
final class CurrencyRateClient
{
#[Cache(cache: 'cache.app', expiresAfter: 300)]
public function ratesFor(string $base, string $quote): array
{
return $this->http->get("/rates/{$base}/{$quote}")->toArray();
}
}
Note final class there is a bug, not a style choice — see Gotchas. Verify it worked with bin/console debug:container CurrencyRateClient (class should be the generated proxy) and by calling the method twice while watching the profiler's cache panel.
Per-argument caching. The key is derived from the method and its arguments, so the annotation is essentially Cache::remember('method:'.md5(serialize($args))). This is the sweet spot for repository/API-gateway methods with scalar parameters:
#[Cache(cache: 'cache.redis', expiresAfter: 3600)]
public function productCountByCategory(int $categoryId, bool $includeChildren = false): int
Explicit keys for non-serializable arguments. If a parameter is an entity, a DateTimeImmutable, or a closure, the default key generation will either blow up or silently collide. Pass an explicit key and keep the argument list out of the hash:
#[Cache(cache: 'cache.app', key: 'exchange_rates_base_eur')]
public function rates(Base $base): array
Decorator pattern (the Laravel-native equivalent). When you leave Symfony, keep the contract the same and swap the mechanism:
class CachedRatesClient
{
public function __construct(private RatesClient $inner) {}
public function ratesFor(string $base, string $quote): array
{
return Cache::remember("rates:{$base}:{$quote}", 300, fn () => $this->inner->ratesFor($base, $quote));
}
}
Bind it in a service provider and you have identical semantics without a compiler pass, proxy files, or non-final classes. Prefer this in Laravel 100% of the time.
Warmup command. Because entries are only populated on first call, add a console command that invokes the expensive annotated methods for your top-N keys, and run it as a post-deploy step. Pair it with cache:pool:clear on the old pool if the payload shape changed.
Test profile. Point the pool at an in-memory/array PSR-6 adapter in the test environment so caches never leak between test cases, and write one test that asserts the inner collaborator is called once across two invocations — that's the only assertion that proves the proxy is actually wired.
Layering with HTTP cache. These are server-side memoization, not response caching. Don't annotate controller actions; annotate the service methods underneath so the cache is reusable across endpoints.
final, annotated methods must not be final/private/static, and the constructor must be overridable. This is the #1 cause of silent no-ops and fatal errors. If your codebase is all final class, this bundle is the wrong tool.$this->ratesFor() inside the same class calls the real method, not the cached one. Same trap as Laravel decorators. Extract the annotated method into its own service.getItem/save — TagAwareAdapter tags are not applied for you. Key rotation or TTL is your only invalidation strategy.expiresAfter unset ≠ forever. It falls through to the pool's default lifetime (cache.app has one). If you need "cache until deploy", set a long explicit TTL rather than assuming.var/cache. In containerized deploys, run cache:warmup at build time in the image, not at runtime, or you'll get permission errors.@Cache annotations are handled by different code; if you're mid-migration and see partial coverage, check which one your class actually uses.debug:container <id> (proxy class name?), check var/cache for the proxy file, check the scanned paths config, then check for final. Entry never expires? → confirm the pool id string matches a real service (debug:container cache.app).Cache annotation + CacheableClassTrait as vendored code you may need to fork. Budget for PHP 8.3/8.4 + ocramius/proxy-manager deprecation noise. If you're choosing today, symfony/cache's #[Cache]-free CacheInterface::get() + a thin decorator gets you 90% of this with zero supply-chain exposure.How can I help you explore Laravel packages today?