tonybogdanov/memoize
Memoize caches the results of expensive function and method calls so repeated invocations return instantly. Simple attributes and helpers make it easy to add per-request or persistent memoization to Laravel and PHP apps, cutting duplicate queries and computation with minimal code changes.
Install and orient yourself. The package is a single-purpose utility — object- and class-level in-memory caching — so the whole surface area fits in a handful of files. Budget 15 minutes:
composer require tonybogdanov/memoize
README.md for the canonical trait/method names.src/ and find the trait that gets applied to a class, plus the store/registry class that actually holds the cache array. That registry is the thing you'll be flushing in tests and in Octane listeners.composer.json for the PHP constraint and whether it declares a Laravel service provider / facade. If it doesn't, this is a plain PHP library you wire up yourself — no config/memoize.php to publish.First use case: memoize a pure, expensive, repeatedly-called method inside one request. The archetypal fit is permission resolution or config derivation on a service object:
use TonyBogdanov\Memoize\Memoize; // confirm exact namespace in src/
class PermissionResolver
{
use Memoize;
public function forUser(User $user): Permissions
{
return $this->memoize(function () use ($user) {
// several queries + policy evaluation
return Permissions::build($user);
});
}
}
Every subsequent forUser($sameUser) in the same request returns the same Permissions instance without re-querying. That's the whole value proposition: you delete a private ?Permissions $cached = null; property and its null-check boilerplate, and you get argument-aware keying for free.
Where it fits and where it doesn't. Reach for this for request-scoped, idempotent, side-effect-free work: repeated policy checks, resolving the same model subtree, formatting/deriving values hit by multiple Blade partials. Do not reach for it as a replacement for Cache::remember() — this is process-local memory with, in all likelihood, no TTL, no tags, and no cross-request sharing.
One caveat up front: with 0 stars and 0 dependents you are effectively the first reviewer. Read the source before adopting it in anything load-bearing — it's small enough that this is a realistic afternoon task, and you should vendor-pin the exact version.
Pattern 1 — Trait on a service, memoize per public method. This is the intended day-to-day shape. Apply the trait to a class, wrap the expensive body, keep the public signature unchanged so callers don't know or care:
class PricingService
{
use Memoize;
public function quote(Order $order): Money
{
return $this->memoize(fn () => $this->calculator->run($order));
}
}
Because the class is bound as a singleton in a service provider, the cache lives exactly as long as the request:
$this->app->singleton(PricingService::class);
If you bind it as a transient, each resolve() yields a fresh instance and the whole exercise is void. Singleton binding is the integration point — get this right and the rest follows.
Pattern 2 — Class-level cache for cross-instance sharing. The "class-level" half of the package description matters when the same computation is requested through different object instances (e.g. a value object constructed in several places). Use it only for genuinely instance-independent data — static config, lookup tables, enum-derived maps. If the cached value depends on $this, class-level caching will silently hand one instance's answer to another. Treat it as a sharp tool.
Pattern 3 — Decorator around a repository, not inside it. Cleaner than annotating the repository itself:
class MemoizedUserRepository implements UserRepository
{
use Memoize;
public function __construct(private UserRepository $inner) {}
public function find(int $id): ?User
{
return $this->memoize(fn () => $this->inner->find($id));
}
}
Bind the decorator in the container. Now the caching concern is swappable, testable in isolation, and you can drop it entirely when profiling says it isn't earning its keep.
Pattern 4 — Argument keying is the feature you're really buying. The package derives a cache key from the method plus its arguments, which is what makes it better than a hand-rolled property. Lean into that: call the same memoized method with $a, $b, $a and expect two computations and one hit. Verify this behaviour explicitly with a counter in a test before you build on it — key derivation (serialization, object identity, ordering of associative arrays) is the most likely place for surprising semantics.
Pattern 5 — Testing. Add a base test case or a tearDown() that flushes the memoization store, otherwise state leaks between tests and you get order-dependent failures that are miserable to debug:
protected function tearDown(): void
{
// flush whatever registry the package exposes
app(PricingService::class)->flushMemoized(); // confirm name
parent::tearDown();
}
Better: assert cache behaviour rather than assuming it. Wrap the inner collaborator in a mock and assert expects(1) for two calls.
Pattern 6 — Octane / queue workers. Under a long-lived process the cache survives across requests (Octane) or across jobs (queue worker). Register a flush in AppServiceProvider or an Octane request-terminated listener so each unit of work starts clean. Without this, memoization quietly becomes a correctness bug rather than a performance win.
It is not a cache in the Laravel sense. No TTL, no tags, no Cache contract, no store abstraction you can point at Redis. It is an array in the process. If a stakeholder hears "caching" and assumes cross-request invalidation, correct them immediately.
Non-pure methods produce stale, wrong answers. Any method whose result depends on mutable state not captured in its arguments — now(), auth()->user(), a model's dirty attributes — will return the first answer forever within the request. This is the number one source of bugs with any memoizer. Audit each wrapped method for hidden inputs.
Long-running processes leak memory. Each distinct argument set creates a new entry that is never evicted. A memoized method called with 10,000 distinct IDs in a queue worker holds 10,000 results plus their arguments in memory. Flush per job, or don't memoize high-cardinality calls.
Returned objects are shared by reference. Callers receive the same instance; a caller that mutates it corrupts the cache for everyone downstream. Return immutable value objects, or clone on the way out.
Key derivation edge cases. Arguments must be reliably hashable. Watch for: objects without stable __toString/serialization, closures as arguments (usually fatal or colliding), associative arrays with different key order mapping to the same logical input, and very large arguments (a full Eloquent model) inflating both the key and memory. Normalize arguments to scalars — pass $user->id, not $user — before calling.
Recursive and internal calls bypass the cache. If a() calls $this->b() directly rather than through the memoized path, b() isn't cached. And recursion inside a memoized method can re-enter before the first result is stored — no in-flight deduplication unless the package explicitly implements it. Check the source.
Don't wrap methods behind __call/magic dispatch without verifying the trait hooks them; trait-based memoizers usually require a concrete, declared method.
Debugging is blind by default. Since hits are silent, add temporary logging or a static counter around a wrapped method to confirm the cache is actually being hit. A memoizer that never hits (because the class is transient, or arguments differ by an object instance each call) looks identical to one that works — until you profile.
No invalidation hooks means you build them. After a write that invalidates derived data, flush explicitly. Keep the memoized lifetime as short as possible: request-scoped by default, method-scoped where you can arrange it.
Extension points are thin, so plan for a fork. The realistic extension surface is: swapping the key normalizer, and wrapping the store. If you need TTL or tag-based invalidation, this package is the wrong tool — compose it with Cache::remember() at the boundary instead, or use spatie/once for the narrow "call once per object" case.
Adoption risk is real. Zero dependents means no community-vetted edge cases and no guarantee of maintenance. Mitigate by (a) pinning an exact version, (b) writing characterisation tests around key derivation and flush semantics so a future replacement is mechanical, and (c) keeping the usage surface to a handful of classes so ripping it out is a diff, not a refactor. Given how small the code is, vendoring the relevant trait into app/Support is a legitimate alternative to a dependency — you'd own ~100 lines and eliminate the supply-chain question entirely.
How can I help you explore Laravel packages today?