At Studocu, we handle hundreds of millions of records and support 2 million daily active users (around 60 million monthly users). At this scale, every millisecond of a user's request is critical. In the early days, we started with a standard cache-aside strategy using Redis as our main cache store.
As Studocu grew from a small team and a simple codebase into one of the top 750 most visited websites globally, scaling our infrastructure introduced new challenges. One major hurdle was caching retrieved database records efficiently and maintaining code simplicity. This article shares how we designed a standardized caching system to ensure performance and maintainability at scale.
With more teams and features, we accumulated legacy practices. We ended up with conflicting caching strategies, scattered cache keys, and a lack of clarity on caching patterns. To address this, we analyzed our existing caching patterns and designed a standardized approach.

Photo by Simone Pellegrini on Unsplash
—
In this article, we cover:
- The challenges of caching in a rapidly expanding codebase.
- Cacheable Entities: a standardized abstraction layer for caching.
- Implementing stale cache invalidation to optimize response times.
- Serializing and deserializing cache values to reduce the memory footprint.
The Problems We Faced
Before we jump into the solution, let’s talk about the issues we were dealing with:
- Scattered Cache Keys and TTLs: Cache keys and TTLs were hardcoded across multiple controllers, event listeners, and tests. Modifying a key or TTL required manual updates in multiple places, which was error-prone.
- Inconsistent Real-time Value Retrieval: It was unclear how to bypass the cache to retrieve fresh, real-time data when needed, leading to inconsistent workarounds.
- Asynchronous Updates: Managing asynchronous cache refreshes lacked a standardized pattern, leading developers to implement custom, ad-hoc solutions.
- Serialization and Deserialization: The serialization and deserialization of cached data was handled inconsistently, increasing complexity and cache sizes.
Addressing these issues allowed us to establish a clean and standardized cache-aside implementation.
What We Needed
As mentioned earlier, we’ve been interacting with the cache infrastructure directly in our application code. These separate parts of the codebase had to know the TTL and the cache key themselves.
We had two primary areas to address:
Key and TTL Management:
- Crafting cache keys.
- TTL encapsulation.
Caching and Accessing Cacheable Values:
- Dealing with the same cacheable values using varied caching strategies (more on this below).
- Making the process of serializing and deserializing cached values more straightforward.
These needs defined our goals for a new, standardized caching library.
Our Approach to Caching
In our scenario, we navigated between two distinct caching strategies: Blocking and Non-blocking cache.
- Blocking Cache (Synchronous): If we don’t have the value, we compute it, cache it, and serve up the result right away.
- Non-blocking Cache (Asynchronous): If we don’t have the value, we dispatch a job to compute it, and return an empty state (like null, empty collection, or an empty array).
Every cacheable value had to support both strategies. Depending on the context, the same entity might be cached or accessed using either a synchronous or asynchronous approach. For instance, a web request might load a non-blocking stale state to keep response times low, whereas a background API request could use the blocking strategy to ensure the latest data is retrieved.
The solution: a.k.a Cacheable Entities
To standardize caching, we developed an opinionated library called "Cacheable Entities." This library provides an abstraction layer that isolates cache logic from application code.
The library is open-source and available in the Cacheable Entities repository. Below, we explain how to integrate and use it.
Defining a cacheable entity
To make a class cacheable entity it has to implement the StuDocu\CacheableEntities\Contracts\Cacheable contract.
The interface implementation requires defining the following methods:
- getCacheTTL: Returns the TTL of the cache in seconds.
- getCacheKey: Returns the cache key.
- get: Computes the Entity value to be cached.
Reading the value of a cacheable entity
We provide two utility classes to execute these strategies: SyncCache and AsyncCache.
StuDocu\CacheableEntities\SyncCache@get: Accepts a cacheable entity and will wait and cache the result if not pre-cached yet. Then returns the value.StuDocu\CacheableEntities\AsyncCache@get: Accepts a cacheable entity and will return the cached value if it is a cache hit. Otherwise, it will dispatch a job to compute the cache value asynchronously and then return an empty
Example
1<?php 2 3use App\Models\Author; 4use App\Models\Book; 5use Illuminate\Database\Eloquent\Collection; 6use StuDocu\CacheableEntities\Contracts\Cacheable; 7 8/** 9 * @phpstan-type ReturnStructure Collection<int, Book> 10 * @implements Cacheable<ReturnStructure> 11 */ 12class AuthorPopularBooksQuery implements Cacheable, SerializableCacheable 13{ 14 public const DEFAULT_LIMIT = 8; 15 16 public function __construct( 17 protected readonly Author $author, 18 protected readonly int $limit = self::DEFAULT_LIMIT, 19 ) { 20 } 21 22 public function getCacheTTL(): int 23 { 24 return 3600 * 24; 25 } 26 27 public function getCacheKey(): string 28 { 29 return "authors:{$this->author->id}:books:popular.v1"; 30 } 31 32 public function get(): Collection 33 { 34 return Book::query() 35 ->join('book_popularity_scores', 'book_popularity_scores.book_id', '=', 'books.id') 36 ->where('author_id', $this->author->id) 37 ->whereValid() 38 ->whereHas('ratings') 39 ->orderByDesc('document_popularity_scores.score') 40 ->take($this->limit) 41 ->get(); 42 } 43} 44 45// Usage 46 47$query = new AuthorPopularBooksQuery($author); 48 49// Get a non-blocking cache result in the web endpoint. 50resolve(\StuDocu\CacheableEntities\AsyncCache::class)->get($query); 51 52// Get a blocking cache result in the API endpoint. 53resolve(\StuDocu\CacheableEntities\SyncCache::class)->get($query);
Serialization/Deserialization
Serializable cache runs transparently behind the scenes, handling data serialization and deserialization without requiring changes to client-side code.
Why it is needed
This pattern reduces the cache size. Instead of caching the full model records, we cache only the metadata, such as an array of model IDs. When reading from the cache, we fetch the full models from the database using these IDs. This database query is fast since it retrieves records directly by primary key, bypassing the complex computation required to find them originally.
The pros and cons are:
-
- Small cache footprint
-
- It allows excluding no longer valid items from the response (like deleted/deactivated models after they were cached).
-
- It doesn’t necessarily reduce the number of DB requests (although it reduces the complexity of the queries).
Usage
To make a cacheable entity serializable, you will need to implement the following contract StuDocu\CacheableEntities\Contracts\SerializableCacheable.
The interface implementation requires defining the following methods:
serialize(mixed $value): mixed: Prepares the result for the cache. It will be called anytime a cacheable entity is about to be cached. The result of this method will be the cache value.unserialize(mixed $value): mixed: Restores the original state of the cached values. It will be called anytime a cache value is read. The result of this method is what will be returned as the cache value.
Example,
1<?php 2 3// [...] 4 5use StuDocu\CacheableEntities\Contracts\SerializableCacheable; 6 7class AuthorPopularBooksQuery implements Cacheable, SerializableCacheable 8{ 9 // [...] 10 11 /** 12 * @param Collection<Book> $value 13 * @return array<int> 14 */ 15 public function serialize(mixed $value): array 16 { 17 // `$value` represents the computed value of this query; it will be what we will get when calling self::get(). 18 return $value->pluck('id')->all(); 19 } 20 21 /** 22 * @param int[] $value 23 * @return Collection<int, Book> 24 */ 25 public function unserialize(mixed $value): Collection 26 { 27 // `$value` represents what we've already cached previously, it will the result of self self::serialize(...) 28 29 $booksFastAccess = array_flip($value); 30 31 $books = Book::query() 32 ->findMany($value) 33 ->sortBy(fn (Book $book) => $booksFastAccess[$book->id] ?? 999) 34 ->values(); 35 36 $this->setRelations($books); 37 38 return $books; 39 } 40 41 /** 42 * @param ReturnStructure $books 43 */ 44 private function setRelations(Collection $books): void 45 { 46 $books->each->setRelation('author', $this->author); 47 48 // Generally speaking, you can do eager loading and such in a similar fashion (for ::get and ::unserialzie). 49 } 50} 51 52// Usage is still unchanged. 53$query = new AuthorPopularBooksQuery($author); 54 55// Get a non-blocking cache result in the web endpoint. 56resolve(\StuDocu\CacheableEntities\AsyncCache::class)->get($query); 57 58// Get a blocking cache result in the API endpoint. 59resolve(\StuDocu\CacheableEntities\SyncCache::class)->get($query);
Because the query class implements SerializableCacheable, serialization and deserialization occur automatically when accessed via the cache utilities. The client API remains identical.
💡 Thanks to PHP type covariance we were able to overwrite the return types of serialize and unserialize from mixed to something more specific.
Caveat when unserializing
Depending on how you serialize models, you might lose the original sort order during deserialization, for example, when storing only raw database IDs.
For Entities where the order matters. Make sure to retain the original order when unserializing.
Here are some examples of how to do so,
1// Retaining the original order with array_search 2$books = Book::query() 3 ->findMany($value) 4 ->sortBy(fn (Book $book) => array_search($book->id, $value)) 5 ->values(); 6 7// Retaining the original order with array_flip. 8// A faster alternative than the above, using direct array access instead of `array_search`.` 9$booksFastAccess = array_flip($value); 10$book = Book::query() 11 ->findMany($value) 12 ->get() 13 ->sortBy(fn (Book $book) => $booksFastAccess[$book->id] ?? 999) 14 ->values(); 15 16// Retaining the original order with SQL. 17$books = Book::query() 18 ->orderByRaw(DB::raw('FIELD(id, ' . implode(',', $value) . ')')) 19 ->get();
Purging the cache
To invalidate the cache value of a cacheable entity, call the SyncCache::forget method. This performs a synchronous invalidation directly on the cache store.
Here are some examples of how to do so,
1<?php 2 3$query = new AuthorPopularBooks($author); 4 5// Invalidate the cache (for example, in an event listener). 6resolve(\StuDocu\CacheableEntities\SyncCache::class)->forget($query);
Async cache default value
When using the AsyncCache utility, it will return null on a cache miss. In some cases, you might need to change the default value. All you need to do is make the cacheable entity implement the following interface StuDocu\CacheableEntities\Contracts\SupportDefaultCacheValue
The interface implementation requires defining the following method:
- getCacheMissValue: specifies the default value on cache miss (when using the async strategy).
Example,
1<?php 2 3// [...] 4 5use Illuminate\Database\Eloquent\Collection; 6use StuDocu\CacheableEntities\Contracts\SupportsDefaultValue; 7 8class AuthorPopularBooks implements Cacheable, SupportsDefaultValue 9{ 10 public function getCacheMissValue(): Collection 11 { 12 return Collection::empty(); 13 } 14}
Generic Annotation
At Studocu, we use PHPStan (via Larastan) for static analysis. Since the return types of our cache utilities are generic, we integrated PHPStan generic type definitions into the Cacheable Entities library. This ensures return types are fully type-safe and validated during static analysis.
Let’s see what generics are available.
CacheableGeneric: This contract accepts one generic definition<TReturn>, which is what the entity will return when callinggetto compute its value.SerializableCacheableGeneric: This contract accepts two generic definitions<TUnserialized, TSerialized>TUnserialized: The type that will be returned when we unserialize the cache value. It should be the same shape asTReturnto ensure consistency.TSerialized: the type that will be returned when we serialize the result.SupportsDefaultValueGeneric: This contract accepts one generic definition<TDefault>, which is what the entity will return when missing the cache while using the AsyncCache utility. It should be the same shape asTReturnto ensure consistency.
Example,
1<?php 2 3/** 4 * @phpstan-type ReturnStructure Collection<User> 5 * @implements Cacheable<ReturnStructure> 6 * @implements SerializableCacheable<ReturnStructure, int[]> 7 * @implements SupportsDefaultValue<ReturnStructure> 8 */ 9class CourseQuery implements Cacheable, SerializableCacheable, SupportsDefaultValue 10{}
Noteworthy Improvements
Implementing this standardized caching system yielded several practical improvements.
Streamlined Developer Experience
Developers now have a consistent, predefined pattern for reading from and writing to the cache, reducing implementation overhead. This architecture also improves codebase transparency by explicitly declaring caching behaviors in the query or entity definitions.
Turbocharging Performance with Async Cache
We also used the asynchronous strategy to implement a stale cache technique for slow queries. When a value is computed, the library saves it in the cache with an extended TTL. When retrieving this value asynchronously, a cache miss prompts the library to return the stale data immediately while dispatching a background job to compute the fresh value and update the cache. This prevents user-facing requests from blocking on heavy database operations, which significantly reduces load times on complex pages.

Stale cache technique performance boost (values are in Nanoseconds).
Note: This technique is a secondary optimization; query optimization remains the primary goal. If legacy database structures or technical limitations prevent further optimization, the stale cache pattern serves as an effective intermediate solution to keep response times low.
Example,
1<?php 2 3use App\Models\Author; 4use App\Models\Book; 5use Illuminate\Database\Eloquent\Collection; 6use StuDocu\CacheableEntities\Contracts\Cacheable; 7use StuDocu\CacheableEntities\Contracts\SupportsDefaultValue; 8 9/** 10 * @phpstan-type ReturnStructure Collection<int, Book> 11 * 12 * @implements Cacheable<ReturnStructure> 13 * @implements SupportsDefaultValue<ReturnStructure> 14 */ 15class AuthorPopularBooksWithStaleCacheQuery implements Cacheable, SupportsDefaultValue 16{ 17 public const DEFAULT_LIMIT = 8; 18 19 public function __construct( 20 protected readonly Author $author, 21 protected readonly int $limit = self::DEFAULT_LIMIT, 22 ) { 23 } 24 25 public function getCacheTTL(): int 26 { 27 return 3600 * 24; 28 } 29 30 public function getCacheKey(): string 31 { 32 return "authors:{$this->author->id}:books:popular.v1"; 33 } 34 35 public function get(): Collection 36 { 37 $books = Book::query() 38 ->join('book_popularity_scores', 'book_popularity_scores.book_id', '=', 'books.id') 39 ->where('author_id', $this->author->id) 40 ->whereValid() 41 ->whereHas('ratings') 42 ->orderByDesc('document_popularity_scores.score') 43 ->take($this->limit) 44 ->get(); 45 46 $this->setRelations($books); 47 48 return tap( 49 $books, 50 function (Collection $results) { 51 cache()->put( 52 $this->getStaleCacheKey(), 53 $results, 54 $this->getStaleCacheTTL(), 55 ); 56 }, 57 ); 58 } 59 60 private function getStaleCacheKey(): string 61 { 62 return $this->getCacheKey() . ':stale'; 63 } 64 65 private function getStaleCacheTTL(): int 66 { 67 return $this->getCacheTTL() + (3600 * 24); 68 } 69 70 public function getCacheMissValue(): Collection 71 { 72 $books = cache()->get($this->getStaleCacheKey(), Collection::empty()); 73 74 if (! ($books instanceof Collection) || $books->isEmpty()) { 75 // When we neither have the up-to-date results nor the stale results cached, we compute 76 // them synchronously as a last resort. 77 return $this->get(); 78 79 // Or you can return an empty collection if you don't want to have a value every time. 80 } 81 82 return $books; 83 } 84 85 /** 86 * @param ReturnStructure $books 87 */ 88 private function setRelations(Collection $books): void 89 { 90 $books->each->setRelation('author', $this->author); 91 92 // Generally speaking, you can do eager loading and such in a similar fashion (for ::get and ::unserialize). 93 } 94}
Some caveats of this approach:
- Treating a synchronous operation as asynchronous can add conceptual complexity.
- If both the primary and stale caches are empty, the value must be computed synchronously during the request, resulting in a slow response for that initial user.
Shrinking Cache Size with Serialization
By implementing the SerializableCacheable contract, we reduced the cache footprint of our models by caching only the primary keys (IDs). This modification reduced our largest cached Redis key size by 97.17%, freeing memory and allowing more keys to be retained. We have since applied this strategy to other large cached objects.
Reducing cache sizes is particularly important when using eviction policies like Least Recently Used (LRU). A smaller footprint prevents premature eviction of active cache keys when memory limits are reached.

Cache share distribution before and after migrating the largest key in the cache (in orange)
Conclusion
Establishing clear caching standards ensures consistency as engineering teams grow. Standardized caching is essential for managing large-scale databases and high-traffic applications. However, caching is not a replacement for query optimization. Database queries should always be optimized before implementing a caching layer.
If you have feedback or suggestions for the package, feel free to open an issue or pull request on the repository.
Curious about the Query classes we touched upon? Dive deeper into their usage at Studocu.