Pop PHP
The Toolkit

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.

BASH
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:

PHP
use Pop\Storage\Storage;

$storage = Storage::createLocal(__DIR__ . '/../var/uploads');

From there, nothing in the calling code names an adapter:

PHP
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.

PHP
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.

PHP
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.

PHP
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:

PHP
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:

PHP
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.

PHP
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.

PHP
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:

PHP
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:

PHP
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():

PHP
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.

PHP
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():

PHP
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:

PHP
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.

PHP
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:

PHP
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 $_FILES reaches uploadFiles() from
  • Auditing — its File adapter writes into a directory you might manage this way
  • pop-storage README — the full storage API
  • pop-dir README — directory traversal, trees and copying