databases storage and caching
Filesystem Cache
A filesystem cache stores computed values as files so PHP can reuse them later without repeating expensive work. It is slower than memory caches such as APCu, Redis, or Memcached, but it is simple, visible, and useful for data that can safely live on disk for a short time.
The main skill is knowing how to make file caching boring and safe. A junior PHP developer should understand cache directories, safe cache keys, expiry, atomic writes, cleanup, and why a file cache must not become the source of truth.
What A Filesystem Cache Is For
A filesystem cache is useful when the application can rebuild the value, but rebuilding it on every request is wasteful.
Common examples include:
- rendered template fragments;
- compiled configuration;
- generated navigation trees;
- expensive API response summaries;
- resized image metadata;
- small report summaries that can be regenerated.
The important word is cache. The original data still lives somewhere else, such as a database, API, configuration file, or object storage.
Use A Private Cache Directory
Cache files should live outside the public web root. A cached value may contain internal IDs, paths, configuration, or user-related information. It should not become downloadable just because the file exists.
<?php
declare(strict_types=1);
function cacheDirectory(string $projectRoot): string
{
return rtrim($projectRoot, DIRECTORY_SEPARATOR) . '/var/cache';
}
echo cacheDirectory('/srv/shop') . PHP_EOL;
// Prints:
// /srv/shop/var/cache
Many frameworks already provide a storage or cache directory. Use that instead of inventing a public path such as /public/cache.
Safe Cache Keys
Do not turn arbitrary user input directly into a filename. Filenames have platform rules, path separators, length limits, and security risks. A common approach is to keep a readable prefix and hash the variable part.
<?php
declare(strict_types=1);
function cacheFilePath(string $cacheDir, string $prefix, string $key): string
{
$safePrefix = preg_replace('/[^a-z0-9_-]/i', '_', $prefix);
$hash = hash('sha256', $key);
return rtrim($cacheDir, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. $safePrefix
. '-'
. $hash
. '.json';
}
echo basename(cacheFilePath('/tmp/app-cache', 'product-summary', 'product:42')) . PHP_EOL;
// Prints:
// product-summary-1cc40f4fd022a3f483f67f2db687adc4ef59dec44c5a59da1d1ca1ba2c4bd874.json
The hash prevents path traversal and keeps filenames predictable in length. The prefix helps humans recognise what kind of cache file they are looking at.
Store Expiry With The Value
Filesystem cache files usually need metadata. At minimum, store when the value expires. A stale cache file should be treated as a miss.
<?php
declare(strict_types=1);
function createCachePayload(array $value, int $ttlSeconds): string
{
return json_encode([
'expires_at' => time() + $ttlSeconds,
'value' => $value,
], JSON_THROW_ON_ERROR);
}
$payload = createCachePayload(['name' => 'Desk lamp'], 300);
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
echo $decoded['value']['name'] . PHP_EOL;
echo $decoded['expires_at'] > time() ? 'fresh' : 'stale';
echo PHP_EOL;
// Prints:
// Desk lamp
// fresh
Using metadata inside the file is clearer than relying only on file modification time. It also makes the cache easier to inspect during debugging.
Reading From A File Cache
A cache read should handle missing files, invalid JSON, expired values, and unexpected shapes. Each of those should behave like a cache miss, not a fatal application error.
<?php
declare(strict_types=1);
function readCacheFile(string $path): ?array
{
if (!is_file($path)) {
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
return null;
}
try {
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
if (!is_array($payload) || !isset($payload['expires_at'], $payload['value'])) {
return null;
}
if (!is_int($payload['expires_at']) || $payload['expires_at'] <= time()) {
return null;
}
return is_array($payload['value']) ? $payload['value'] : null;
}
$missing = readCacheFile(sys_get_temp_dir() . '/missing-cache-file.json');
echo $missing === null ? 'cache miss' : 'cache hit';
echo PHP_EOL;
// Prints:
// cache miss
This defensive shape matters in real applications because cache files can be deleted, truncated, left behind by old code, or manually edited during debugging.
Writing Atomically
Do not write directly to the final cache file if another request might read it at the same time. A half-written JSON file can cause confusing failures. Write to a temporary file first, then rename it into place.
<?php
declare(strict_types=1);
function writeCacheFile(string $path, array $value, int $ttlSeconds): void
{
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$payload = json_encode([
'expires_at' => time() + $ttlSeconds,
'value' => $value,
], JSON_THROW_ON_ERROR);
$temporaryPath = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
file_put_contents($temporaryPath, $payload, LOCK_EX);
rename($temporaryPath, $path);
}
$path = sys_get_temp_dir() . '/filesystem-cache-example/product-42.json';
writeCacheFile($path, ['id' => 42, 'name' => 'Desk lamp'], 60);
echo is_file($path) ? 'cache file exists' : 'cache file missing';
echo PHP_EOL;
unlink($path);
rmdir(dirname($path));
// Prints:
// cache file exists
On a single local filesystem, rename() is normally atomic when the temporary file and final file are on the same filesystem. That means readers should either see the old complete file or the new complete file, not a half-written file.
Combining Read, Miss, And Write
The usual pattern is read, rebuild on miss, write the fresh value, then return it.
<?php
declare(strict_types=1);
function productSummaryCacheDecision(bool $cacheFileIsFresh): array
{
if ($cacheFileIsFresh) {
return [
'source' => 'filesystem cache',
'action' => 'decode the cached product summary and return it',
];
}
return [
'source' => 'loader',
'action' => 'rebuild the product summary, write a fresh cache file, and return it',
];
}
print_r(productSummaryCacheDecision(false));
// Prints:
// [source] => loader
// [action] => rebuild the product summary, write a fresh cache file, and return it
The loader is still the real source. The cache only avoids calling it when a fresh value already exists.
Cleanup
Filesystem caches need cleanup. Expired files take disk space, and large cache directories can become slow to scan.
Cleanup can happen through:
- a scheduled command that deletes expired files;
- a framework cache command;
- a deploy step that clears compiled or derived cache;
- occasional opportunistic cleanup during writes, if kept cheap.
Avoid deleting broad directories unless the path is clearly controlled by the application. A bad cleanup command can remove real files.
When Filesystem Cache Is The Wrong Tool
A filesystem cache is often the wrong choice when the application runs across multiple servers. Each server has its own local files unless the cache directory is on shared storage, and shared network filesystems introduce their own locking and performance problems.
Use Redis or Memcached when the cache must be shared across several web servers. Use APCu when the value is small, local, and only needed inside one PHP server. Use the database or durable storage when the value is source-of-truth data.
What To Check
Before moving on, make sure you can:
- explain that a filesystem cache stores rebuildable values as private files;
- create safe cache filenames without trusting raw user input;
- store and check expiry metadata;
- treat missing, expired, or corrupt cache files as misses;
- write through a temporary file and
rename()to avoid partial reads; - keep cache files outside the public web root;
- choose Redis, Memcached, APCu, or durable storage when a file cache is the wrong fit.
Practice
Practice: Build A Small Filesystem Cache
Create a tiny filesystem cache for product summaries.
Requirements
- Build a safe cache filename from a cache directory, prefix, and key.
- Store JSON with an
expires_atvalue and avaluepayload. - Return
nullwhen the file is missing, expired, unreadable, invalid JSON, or the payload shape is wrong. - Write the cache through a temporary file before renaming it into place.
- Demonstrate a normal read and an expired read.
- Remove any temporary files created by the example.
Keep the cache directory outside any public web path.
Show solution
This solution keeps the cache file private, hashes the key into a safe filename, stores expiry metadata, and treats broken cache files as misses.
<?php
declare(strict_types=1);
function cacheFilePath(string $cacheDir, string $prefix, string $key): string
{
$safePrefix = preg_replace('/[^a-z0-9_-]/i', '_', $prefix);
return rtrim($cacheDir, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. $safePrefix
. '-'
. hash('sha256', $key)
. '.json';
}
function readFileCache(string $path): ?array
{
if (!is_file($path)) {
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
return null;
}
try {
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
if (!is_array($payload) || !isset($payload['expires_at'], $payload['value'])) {
return null;
}
if (!is_int($payload['expires_at']) || $payload['expires_at'] <= time()) {
return null;
}
return is_array($payload['value']) ? $payload['value'] : null;
}
function writeFileCache(string $path, array $value, int $ttlSeconds): void
{
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$payload = json_encode([
'expires_at' => time() + $ttlSeconds,
'value' => $value,
], JSON_THROW_ON_ERROR);
$temporaryPath = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
file_put_contents($temporaryPath, $payload, LOCK_EX);
rename($temporaryPath, $path);
}
$cacheDir = sys_get_temp_dir() . '/product-summary-cache-example';
$path = cacheFilePath($cacheDir, 'product-summary', 'product:42');
writeFileCache($path, ['id' => 42, 'name' => 'Desk lamp'], 60);
$fresh = readFileCache($path);
writeFileCache($path, ['id' => 42, 'name' => 'Old lamp'], -1);
$expired = readFileCache($path);
echo $fresh['name'] . PHP_EOL;
echo $expired === null ? 'expired cache missed' : 'expired cache returned';
echo PHP_EOL;
unlink($path);
rmdir($cacheDir);
// Prints:
// Desk lamp
// expired cache missed
The cache behaves correctly because it is optional. A missing, expired, or corrupt file becomes a miss, and the application can rebuild the product summary from the real data source.