key($anagram, $minLength); // Cache a plain array: a serialised Collection comes back as // __PHP_Incomplete_Class through the database store. $words = Cache::rememberForever( $key, fn () => Matcher::findWords($this->normalise($anagram), $minLength) ->values() ->all(), ); return new Collection($words); } /** * Entries live forever; the dictionary hash in the key is the invalidation * mechanism. Replacing words.txt makes every old key unreachable rather * than stale, so nothing has to be flushed by hand. */ private function key(string $anagram, int $minLength): string { return sprintf( 'anagram:%s:%d:%s', $this->dictionaryHash(), $minLength, $this->normalise($anagram), ); } /** * Sorted letters, so "eamstoxil" and "latoximse" share one entry — the same * bag of letters is the same query. */ private function normalise(string $anagram): string { $letters = str_split(strtolower($anagram)); sort($letters); return implode('', $letters); } /** * Content-addressed, not mtime: the dictionary ships inside the image, so * its mtime changes on every rebuild even when the words do not. * * Hashing 4.6MB per request would undo the saving, so the hash is itself * cached under a key made of the cheap stat values that change whenever the * file is replaced. */ private function dictionaryHash(): string { $path = self::DICTIONARY; if (! is_file($path)) { return 'nodict'; } $stat = filemtime($path) . ':' . filesize($path); return Cache::rememberForever( "anagram:dict:{$stat}", fn () => hash_file('xxh128', $path), ); } }