File Storage
pop-storage puts one interface over local disk, AWS S3 and Azure Blob Storage, so a laptop writing into
var/uploads and a production box writing into a bucket run the same code.
composer require popphp/pop-storage popphp/pop-dir
One API over Several Backends#
Pop\Storage\Storage is the object you hold. It delegates every call to an adapter implementing
Pop\Storage\StorageInterface, and three static factories build the pairing:
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
From there, nothing in the calling code names an adapter:
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
$storage->putFileContents('invoice.pdf', 'PDF bytes here');
$contents = $storage->fetchFile('invoice.pdf');
$size = $storage->getFileSize('invoice.pdf');
$storage->copyFile('invoice.pdf', 'archive/invoice.pdf');
$storage->deleteFile('invoice.pdf');
Every operation either succeeds or throws — there are no false returns to test. The twelve types under
Pop\Storage\Exception\* (FileNotFoundException, UnableToWriteFileException,
UnsupportedOperationException, PathTraversalException and the rest) all extend
Pop\Storage\Exception, so one catch covers everything and a specific type is there when you want to
branch.
use Pop\Storage\Exception;
use Pop\Storage\Exception\FileNotFoundException;
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
try {
$contents = $storage->fetchFile('invoice.pdf');
} catch (FileNotFoundException $e) {
$contents = null;
} catch (Exception $e) {
throw $e;
}
fileExists(), isDir() and isFile() are questions rather than operations, so a missing path is a plain
false. They still throw PathTraversalException for a .. segment.
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
$storage->fetchFile('../../etc/passwd');
// Pop\Storage\Exception\PathTraversalException: Error: The path '../../etc/passwd'
// is not allowed to traverse outside of the storage directory.
A single leading /, \, ./ or .\ is normalized away rather than rejected, so ordinary paths pass
through untouched.
Local Disk#
Storage::createLocal() takes the directory that becomes the storage root. Every path you pass after
that's relative to it, and nothing can address anything above it.
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
$storage->putFile('/tmp/report.pdf'); // a file already on disk
$storage->putFileContents('notes.txt', "Hello World"); // contents you have in hand
$storage->uploadFiles($_FILES); // a whole request's uploads
putFileContents() writes whether or not the file was there; replaceFileContents() insists it already
exists and throws FileNotFoundException when it does not.
Reading back comes in three shapes:
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
$contents = $storage->fetchFile('notes.txt'); // the whole thing as a string
$info = $storage->fetchFileInfo('notes.txt'); // metadata, no contents
$resource = $storage->fetchFileStream('notes.txt');
while (!feof($resource)) {
echo fread($resource, 8192);
}
fclose($resource);
fetchFileInfo() returns basename, filename, extension, path, size and mime_type. The stream
pair — fetchFileStream() and putFileStream() — moves a large file through a resource rather than
through memory, and the resource it hands back is yours to close.
The four metadata helpers never return false; a missing file throws FileNotFoundException:
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
$storage->getFileSize('notes.txt'); // 11
$storage->getFileType('notes.txt'); // 'file'
$storage->getFileMTime('notes.txt'); // 1787583297
$storage->md5File('notes.txt'); // 'b10a8db164e0754105b7a99be72e3fe5'
Directories work through mkdir(), chdir() and rmdir(). chdir() moves the current location; the
base directory set at construction never moves.
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
$storage->mkdir('2026');
$storage->chdir('2026');
$storage->putFileContents('january.csv', 'id,total');
$storage->chdir(); // back to the base directory
chdir() always resolves from the base directory, so it's not cumulative: chdir('2026') then
chdir('2027') lands on 2027, not 2026/2027. getBaseDir() reports the fixed root and
getCurrentDir() reports where chdir() left you.
mkdir() creates relative to the current directory and chdir() resolves from the base directory, so
call chdir() first and then create.
rmdir() on the local adapter removes the directory and everything under it.
The local adapter has no temporary URL. getTemporaryUrl() throws
Pop\Storage\Exception\UnsupportedOperationException with "Error: Temporary URLs are not supported by
the local disk adapter." — worth knowing before a feature built on presigned URLs reaches a laptop.
AWS S3#
The S3 and Azure sections below were written from pop-storage's source rather than run against a live
bucket or container.
The S3 adapter wraps an Aws\S3\S3Client you build yourself, which keeps credential handling in the AWS
SDK where it belongs. Storage::createS3() takes the bucket and that client.
use Aws\S3\S3Client;
use Pop\Storage\Storage;
$storage = Storage::createS3('my-bucket', new S3Client([
'credentials' => [
'key' => getenv('AWS_KEY'),
'secret' => getenv('AWS_SECRET'),
],
'region' => getenv('AWS_REGION'),
'version' => 'latest',
]));
The bucket argument is normalized to carry the s3:// prefix the stream wrapper needs, so 'my-bucket'
and 's3://my-bucket' both work and getBaseDir() reports 's3://my-bucket' either way. Construction
contacts nothing, so a bad key surfaces at the first operation rather than at bootstrap.
S3 has no real directories, only key prefixes, and pop-storage presents those as directories.
mkdir() works on an empty one by writing a zero-byte object whose key ends in /; a failure comes back
as UnableToCreateDirectoryException wrapping the AWS exception.
The presigned URL is the capability worth reaching for — time-limited read access to a private object without exposing credentials or making it public:
use Aws\S3\S3Client;
use Pop\Storage\Storage;
$storage = Storage::createS3('my-bucket', new S3Client([
'credentials' => ['key' => getenv('AWS_KEY'), 'secret' => getenv('AWS_SECRET')],
'region' => getenv('AWS_REGION'),
'version' => 'latest',
]));
$url = $storage->getTemporaryUrl('invoice.pdf'); // 900 seconds
$url = $storage->getTemporaryUrl('invoice.pdf', 3600); // an hour
Anything outside the bucket the object was built with goes through the external pair —
copyFileToExternal(), moveFileToExternal(), copyFileFromExternal() and moveFileFromExternal() —
which take an s3://other-bucket/key path on the far side.
Azure Blob#
The Azure adapter needs an account name, an account key and a container, and builds its own HTTP client rather than taking an SDK object:
use Pop\Storage\Storage;
$storage = Storage::createAzure(getenv('AZURE_ACCOUNT'), getenv('AZURE_KEY'), 'uploads');
The container becomes the base directory. As with S3, building the object performs no network call.
Azure does not allow explicit creation or removal of empty directories. A prefix appears when a file is written under it and disappears when the last file under it is deleted, so writing the file is how you get the directory.
Azure blob storage has no real directories, so Azure::mkdir() and rmdir() accept the call and return.
Use prefixed blob names for hierarchy.
getTemporaryUrl() works here too, returning a SAS-token URL rather than an S3 presigned one — the
signature differs, the calling code does not.
Two capabilities exist only on the Azure adapter and so are not on Pop\Storage\StorageInterface. Reach
them through adapter(), the shorter alias for getAdapter():
use Pop\Storage\Storage;
$storage = Storage::createAzure(getenv('AZURE_ACCOUNT'), getenv('AZURE_KEY'), 'uploads');
// Pass false to get the raw Pop\Http\Client\Response instead of the file contents
$response = $storage->adapter()->fetchFile('invoice.pdf', false);
// Delete only a blob's snapshots and leave the blob itself
$storage->adapter()->deleteFile('invoice.pdf', 'only');
The trailing argument on deleteFile() and moveFileFromExternal() controls Azure's
x-ms-delete-snapshots header: 'include' (the default) removes the blob and its snapshots, 'only'
removes the snapshots alone, and null omits the header.
Listing and Traversing Directories#
Three listing methods work the same way on every adapter, each taking an optional glob-style search and a
$recursive flag that defaults to false — one level below the current location.
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
$storage->listFiles(); // ['notes.txt']
$storage->listDirs(); // ['2026/']
$storage->listAll(); // ['2026/', 'notes.txt']
$storage->listFiles('*.pdf'); // files matching the pattern
$storage->listFiles(null, true); // ['2026/january.csv', 'notes.txt']
Directory entries carry a trailing slash and files do not, which is how you tell them apart in a
listAll() result. Recursive results are paths relative to the current location, so each one goes
straight back into fetchFile() or deleteFile():
use Pop\Storage\Storage;
$storage = Storage::createLocal(__DIR__ . '/../var/uploads');
foreach ($storage->listFiles(null, true) as $file) {
$contents = $storage->fetchFile($file);
}
pop-dir#
For the local filesystem, pop-dir is the smaller tool. Pop\Dir\Dir takes a path and a handful of
boolean options and gives you the contents as an array:
use Pop\Dir\Dir;
$dir = new Dir(__DIR__ . '/../var/uploads');
foreach ($dir->getFiles() as $file) {
echo $file;
}
Four options shape what lands in that array.
| Option | Default | Effect on each entry |
|---|---|---|
absolute |
false |
The full path, /var/uploads/2026/january.csv |
relative |
false |
The path below the root, 2026/january.csv |
recursive |
false |
Walks subdirectories rather than stopping at the top level |
filesOnly |
false |
Omits directory entries from the list |
absolute and relative cannot both apply — pass both and relative wins.
use Pop\Dir\Dir;
$dir = new Dir(__DIR__ . '/../var/uploads', [
'relative' => true,
'recursive' => true,
'filesOnly' => true,
]);
$dir->getFiles(); // ['notes.txt', '2026/january.csv']
Set absolute or relative on a recursive Dir to get full paths back rather than bare basenames.
Each option also has a fluent setter, and calling one re-scans immediately:
use Pop\Dir\Dir;
$dir = new Dir(__DIR__ . '/../var/uploads');
$dir->setRecursive(true)->setRelative(true);
count($dir); // Dir implements Countable, IteratorAggregate and ArrayAccess
getTree() is the other view — a nested array keyed by the resolved root path, subdirectories appearing
as keys prefixed by a directory separator and files as plain numeric entries. A subdirectory that is a
symlink is never expanded, which also stops a symlink cycle.
copyTo() copies a whole directory; deleteFile(), or unset($dir['file1.txt']), removes one file from
disk and from the object's list. Deleting a directory entry, a missing entry or a non-writable file
throws Pop\Dir\Exception, and so does constructing a Dir on a path that does not exist.
Create the destination before calling copyTo().
Both components go further than this page does — see the pop-storage and pop-dir READMEs.
See Also#
- Cache — the other component with one interface over interchangeable backends
- Requests & Responses — where
$_FILESreachesuploadFiles()from - Auditing — its
Fileadapter writes into a directory you might manage this way - pop-storage README — the full storage API
- pop-dir README — directory traversal, trees and copying