ctw / ctw-temp
Easily create, use and destroy temporary files and directories.
Requires
- php: ^8.5
- ext-posix: *
Requires (Dev)
- ctw/ctw-qa: ^6.0
- phpunit/phpunit: ^13.3
- symfony/var-dumper: ^8.0
README
Zero-dependency generator for per-user, per-application temporary files and directories
on disk, for PHP 8.5+ applications.
Features
- Builds temporary paths of the form
<basePath>[/<hash>]/<id>[/<levelN>], defaulting to real disk (/var/tmp/php) instead of the RAM-backedsys_get_temp_dir(). - Optional per-user/group
<hash>segment isolates one user's files from another's on shared hosts. - Provisions the tree in two permission tiers: a world-writable shared base and per-user directories beneath it.
- Atomically creates uniquely named files, with deletion guarded against path traversal outside the directory.
- Empties a directory in constant time by renaming it aside to a trash
directory, so a cache purge never makes a request wait on millions of
unlink()calls. - Ships a self-contained cron script that reclaims the space those trash directories hold, slowly and out of the request path — see bin/README.md.
- Rebuilds an instance from a path it previously assembled, so bootstrap can keep only the path constant and a later request can recover the object.
- Consistent
Path/Filemethod naming and a single exception interface for catching every failure mode. - No package dependencies; requires only
ext-posix.
Introduction
Why This Library Exists
Applications commonly derive a temporary working directory from
sys_get_temp_dir(). Since Debian 14, that location (/tmp) is a tmpfs
(RAM-backed) mount that fills up quickly under write-heavy workloads such as
image processing, PDF generation, or page caching.
ctw/ctw-temp moves temporary storage onto real disk (default
/var/tmp/php) and encapsulates the path-construction logic — previously
duplicated inline across the application — behind a single, tested class with
no package dependencies.
It replaces constructions such as:
define('APP_PATH_TEMP', sprintf( '%s/%s_%s_temp', sys_get_temp_dir(), 'www.example.com', hash('crc32b', 'www-data_www-data'), )); mkdir(APP_PATH_TEMP, 0777, true);
with:
use Ctw\Temp\Temp; $temp = new Temp('www.example.com'); // base defaults to /var/tmp/php define('APP_PATH_TEMP', $temp->createPath());
The Path Model
<basePath>[/<hash>]/<id>[/<levelN>]
For example:
/var/tmp/php/78b43994/www.example.com/page-cache
| Segment | Meaning | Configurable | Default | Required |
|---|---|---|---|---|
basePath |
Base path holding the temporary tree | yes | /var/tmp/php |
– |
hash |
crc32b of the sanitized <user>_<group> (8 hex chars) |
on/off | included | – |
id |
Application identifier or hostname | – | – | yes |
levelN |
Optional n-level directory, or a list of nested directories | yes | omitted | no |
The hash segment isolates one user/group's files from another's on shared
hosts. It is derived from the process user and group via the POSIX extension
(encapsulated in Ctw\Temp\Posix, injectable into Temp), so
ext-posix is required.
Requirements
- PHP 8.5+
ext-posix
Installation
composer require ctw/ctw-temp
Usage
use Ctw\Temp\Temp; // Full constructor signature (only $id is required): $temp = new Temp( id: 'www.example.com', levelN: 'page-cache', // optional extra directory includeUserGroup: true, // include the per-user/group <hash> segment basePath: '/var/tmp/php', // default ); $temp->getPath(); // '/var/tmp/php/78b43994/www.example.com/page-cache' $temp->createPath(); // mkdir -p; throws RuntimeException if not writable $temp->existsPath(); // bool // Create and later delete a uniquely named file inside the directory: $file = $temp->createFile('report', 'pdf'); // '/…/report-a1b2c3d4e5f6a7b8.pdf' $temp->deleteFile($file); $temp->clearPath(); // empty the directory, keep it (e.g. cache reset) $temp->trashPath(); // same, in constant time; returns '/var/tmp/php/<hash>/.trash-9f3a1c2b7d4e5061' $temp->deletePath(); // recursively remove the directory and its contents
$levelN also accepts a list, which nests one directory per element:
$temp = new Temp('www.example.com', ['page-cache', 'v2']); $temp->getPath(); // '/var/tmp/php/78b43994/www.example.com/page-cache/v2'
Rebuilding the Instance From a Path
Bootstrap code usually keeps only the assembled path, as an application constant:
// bootstrap.php define('APP_PATH_TEMP', new Temp(hostname_www())->createPath());
A later request — a cache-invalidation endpoint, say — needs the object back
rather than the string. createFromPath() rebuilds one that is equal to the
original, so no constructor arguments have to be threaded through the
application:
// invalidate.php $temp = Temp::createFromPath(APP_PATH_TEMP); $temp->trashPath();
$temp = new Temp('www.example.com'); Temp::createFromPath($temp->getPath()) == $temp; // true
Nothing is read from disk and no directory is created, matching the plain
constructor. The path is split into segments below the base path, so the two
permission tiers described under Permissions survive the round
trip. The <hash> segment is carried over verbatim rather than recomputed, so
the rebuilt instance keeps pointing at the same directory even when the process
doing the rebuilding runs as a different user than the one that created it.
Pass $basePath when the tree was not built on the default:
$temp = Temp::createFromPath('/srv/tmp/www.example.com/page-cache', '/srv/tmp');
A path that does not lie beneath the base path, or that this class would never
have produced (because sanitization would alter it), is rejected with
InvalidPathException rather than silently resolving to a different directory.
Injecting the Instance
Ctw\Temp\TempTrait supplies the property and the getter/setter pair, so
classes that work with the temporary tree do not each redeclare them:
use Ctw\Temp\Temp; use Ctw\Temp\TempTrait; final class PageCache { use TempTrait; public function invalidate(): void { $this->getTemp()->trashPath(); } } $cache = new PageCache()->setTemp(Temp::createFromPath(APP_PATH_TEMP));
setTemp() returns $this, so it chains. The property is typed but not
initialized: calling getTemp() before setTemp() raises an Error, exactly
as an uninitialized typed property does anywhere else — a missing injection
fails loudly instead of yielding null.
Public API
Method names carry a Path (directory) or File (file) qualifier so it is
always clear which is being operated on. In the source, directory operations
and file operations are grouped into separate, clearly marked sections.
| Method | Operates on | Description |
|---|---|---|
createFromPath(string $path): self |
– | Rebuild an instance from a path this class assembled (static). |
getPath(): string |
– | The assembled path (does not create it). |
createPath(): string |
directory | Create the directory recursively; throws if it cannot be written. |
deletePath(): bool |
directory | Recursively delete the directory and its contents. |
existsPath(): bool |
directory | Whether the directory currently exists. |
clearPath(): void |
directory | Remove the directory's contents but keep the directory. |
trashPath(): string |
directory | Empty the directory in constant time; returns the trash path. |
createFile(string $name, string $ext) |
file | Atomically create a uniquely named file; returns its absolute path. |
deleteFile(string $file): bool |
file | Delete a file inside the directory (refuses paths outside it). |
Constant-Time Purge
clearPath() costs time proportional to the number of files it removes, and the
request that asked for the purge waits for every last one. trashPath() costs
the same whether the directory holds ten files or ten million: it renames the
directory out of the way and creates a fresh empty one in its place. Nothing
inside is touched — only the entry in the parent directory changes.
before after
------ -----
/var/tmp/php/78b43994/ /var/tmp/php/78b43994/
└── www.example.com/ ├── .trash-9f3a1c2b7d4e5061/ ← the same 4M files
├── page-cache/ ← 4M files └── www.example.com/
└── sessions/ ├── page-cache/ ← new, empty
└── sessions/ ← untouched
The trash directory is collected at the user/group level — one directory
below the base path — however deep the directory being emptied lies. The base
path itself was considered for this and rejected: it is world-writable (0777),
so any local user could rename the trash away or squat on a candidate name, while
the user/group segment is 0755 and owned by the user doing the purge. A rename
cannot cross filesystems, so the whole temporary tree is assumed to sit on one.
One shape has no such segment to collect into. A Temp built with no user/group
segment and no levelN sits one directory below the base, and the base is the
only parent it has, so its trash falls back there. That is the single case where
the 0777 exposure described above applies to the trash itself. The default
constructor keeps the user/group segment and never reaches it.
The name is .trash- plus a random sixteen-hex-character token: the leading dot
keeps it out of a normal listing, and the random suffix lets any number of purges
be in flight without colliding — which matters more now that every purge for one
user lands in the same directory. The token is deliberately not derived from the
clock. A clock-derived value gave no usable ordering once it wrapped (every 71
minutes), and rename() preserves the directory's mtime, so find -mtime or
stat reports the real age whenever it is wanted. The replacement directory
keeps the permissions the original had.
Collecting the trash at a fixed depth also keeps it out of the live application
directories. Left beside the directory it came from, a trash directory would sit
among the siblings — sessions/, locks/, another cache — that other instances
are still using, and a clearPath() or deletePath() on an ancestor would walk
straight into it (FilesystemIterator::SKIP_DOTS skips . and .., not dotted
directory names) and unlink every file in it in-request, reintroducing exactly the
stall trashPath() exists to remove.
Three things to know before using it:
-
Everything in the directory goes, not just the cache. If session files or lock files live alongside it, a purge discards those too. Point
levelNat a dedicated sub-directory (e.g.new Temp('www.example.com', 'page-cache')) when the application keeps anything else in its temporary directory. -
No disk space is freed. The files are still there under the trash name.
-
Nothing in the library removes the trash directories. That is deliberately a separate, privileged concern, handled by the two cron scripts described below. Find them by hand with:
find /var/tmp/php -mindepth 1 -maxdepth 2 -type d -name '.trash-*'The depth bound does the important work: trash is only ever one or two levels below the base, so the search stops there instead of descending into the millions of files inside a trash directory — or walking a live cache tree at all. For the same reason, do not run
duagainst them casually — measuring the size costs as much as deleting them would.
Clearing the Trash
Reclaiming the disk space is the job of bin/clear_trash.php,
a nightly cron script run as root. It is self-contained — it loads no autoloader
and does not need this package installed — and it deletes slowly on purpose, in
small batches at the lowest scheduling priority, so a purge holding millions of
files never starves the web server.
17 4 * * * /root/bin/clear_trash.php
It is held to two rules, proven by test/ClearTrashTest.php: nothing outside
the base path is ever touched, and inside it only directories named .trash-*
are removed — never a file or a symlink that merely carries the name.
Its base path defaults to /var/tmp/php, matching Temp::DEFAULT_BASE_PATH. An
application that passed a different $basePath needs the collector pointed at
the same place:
17 4 * * * /root/bin/clear_trash.php --base-path=/srv/tmp
See bin/README.md for the full documentation: how to download and install it on a production machine, the first dry run, scheduling, locking, exit codes and tuning.
Errors
Every exception implements Ctw\Temp\Exception\ExceptionInterface, so a
single catch (ExceptionInterface $e) traps them all. Specific types let you
catch individual failure modes:
| Exception | Extends | Thrown when |
|---|---|---|
InvalidBasePathException |
InvalidArgumentException |
the configured base path is empty |
InvalidPathException |
InvalidArgumentException |
a createFromPath() argument is not beneath the base path |
InvalidPathSegmentException |
InvalidArgumentException |
an id/levelN segment is empty or unsafe |
DirectoryNotCreatedException |
RuntimeException |
a base or per-user directory cannot be created |
DirectoryNotWritableException |
RuntimeException |
the temporary directory exists but is not writable |
DirectoryNotFoundException |
RuntimeException |
trashPath() is called and the directory does not exist |
DirectoryNotRenamedException |
RuntimeException |
the directory cannot be renamed aside to a trash directory |
FileNotCreatedException |
RuntimeException |
a unique file cannot be created |
PathTraversalException |
RuntimeException |
a deleteFile() target resolves outside the directory |
PosixUnavailableException |
RuntimeException |
the POSIX extension is required but not loaded |
RuntimeException and InvalidArgumentException (both in
Ctw\Temp\Exception) remain as intermediate base classes for broad catches.
Permissions
createPath() provisions the tree in two tiers:
- the shared base path (
/var/tmp/php) is created world-writable (0777, forced withchmod()so it survives the umask), like/tmp, so both a CLI/deploy user and the web server user (www-data) can create their own<hash>subtree beneath it; - the per-user directories below it use
0755(configurable via thepathModeconstructor argument) and are owned by the creating user.
Whichever process runs first creates and opens up the base; the other then finds
it already in place. No manual provisioning step is required, though ops may
pre-create /var/tmp/php with mode 0777 if preferred.
Testing
composer test # PHPUnit test suite (with coverage) composer qa # Rector (dry-run), ECS, and PHPStan (max level) composer qa-fix # Apply Rector and ECS fixes, then run PHPStan
Contributing
Create a feature branch, keep the code declare(strict_types=1); and PSR-12
compliant, run composer qa until it reports no issues, and open a merge
request.
Changelog
See CHANGELOG.md.
License
BSD-3-Clause. See LICENSE.md.
Maintainer
Maintained by CTW. Report issues and propose changes on the project's repository.