Skip to content

Filesystem

Local filesystem and S3 with one API: without AWS SDK, without Flysystem.

Introduction

Filesystem abstraction in PHP usually means: install Flysystem, add the AWS SDK, and hope that the 200+ classes play nicely together. For a simple task (read, write, copy, list files) that's considerable overhead.

jardisadapter/filesystem replaces both. A lean abstraction for local filesystem and S3-compatible object storage (AWS S3, MinIO, DigitalOcean Spaces), implemented with pure PHP and cURL:

  • No AWS SDK, no Flysystem — S3 communication directly with cURL and AWS Signature Version 4
  • One API for both backends — identical PHP code works against local disk and S3
  • Reader/Writer contracts — read-only consumers inject FilesystemReaderInterface, writers FilesystemWriterInterface; FilesystemInterface combines both
  • Stream supportreadStream() and writeStream() for large files without memory overhead
  • Security by default — path traversal protection, null byte rejection, symlink escape detection, XXE protection for S3 XML parsing
  • Visibility — Unix permissions (local) and ACLs (S3) via getVisibility()/setVisibility()
  • Multiple instances — local upload storage and S3 backup in the same process with different configurations

Installation

bash
composer require jardisadapter/filesystem

GitHub: jardisAdapter/filesystem

Required PHP extensions:

ExtensionFor
ext-curlS3 communication
ext-fileinfoMIME type detection
ext-simplexmlS3 response parsing

Basic Usage

Local Filesystem

php
use JardisAdapter\Filesystem\FilesystemService;

$fs = (new FilesystemService())->local('/var/app/storage');

// Write (directories are created automatically)
$fs->write('uploads/report.pdf', $pdfContent);

// Read
$content = $fs->read('uploads/report.pdf');

// Check
$fs->exists('uploads/report.pdf');     // true
$fs->size('uploads/report.pdf');       // bytes
$fs->mimeType('uploads/report.pdf');   // 'application/pdf'
$fs->lastModified('uploads/report.pdf'); // Unix timestamp

// Copy, move, delete
$fs->copy('uploads/report.pdf', 'archive/report.pdf');
$fs->move('archive/report.pdf', 'archive/2025/report.pdf');
$fs->delete('uploads/report.pdf');

// Directories
$fs->createDirectory('exports');
$fs->deleteDirectory('temp');  // Recursive

S3-Compatible Storage

php
$s3 = (new FilesystemService())->s3(
    bucket: 'my-bucket',
    region: 'eu-central-1',
    key: 'AKIAEXAMPLE',
    secret: 'wJalrXUtnFEMI...',
);

// Same API as local
$s3->write('backups/2025-04-05.sql.gz', $dumpContent);
$content = $s3->read('backups/2025-04-05.sql.gz');
$s3->exists('backups/2025-04-05.sql.gz');  // true

MinIO / Custom Endpoint

php
$minio = (new FilesystemService())->s3(
    bucket: 'local-dev',
    region: 'us-east-1',
    key: 'minioadmin',
    secret: 'minioadmin',
    endpoint: 'http://localhost:9000',
    prefix: 'uploads/',   // Prefix for all keys
);

Streams

For large files that don't fit entirely in memory:

php
// Write from a stream
$source = fopen('/tmp/large-export.csv', 'rb');
$fs->writeStream('exports/large.csv', $source);
fclose($source);

// Read as stream
$stream = $fs->readStream('exports/large.csv');
while (!feof($stream)) {
    $chunk = fread($stream, 8192);
    // Process chunk
}
fclose($stream);

S3 Write Stream

For S3, the stream is read into memory before upload. For very large files (> 100 MB), multipart upload should be used (not yet implemented).

Listing Directories

php
// Flat — only direct children
foreach ($fs->listContents('uploads') as $item) {
    $item->path();          // 'uploads/report.pdf'
    $item->size();          // bytes (0 for directories)
    $item->lastModified();  // Unix timestamp
    $item->isFile();        // true
    $item->isDirectory();   // false
}

// Recursive — entire tree
foreach ($fs->listContents('', recursive: true) as $item) {
    echo $item->path() . "\n";
}

S3 listings are automatically paginated: even with thousands of objects, all pages are traversed transparently.

Visibility

getVisibility()/setVisibility() are not part of FilesystemInterface (nor of the Reader/Writer contracts): they live only on the concrete Filesystem object. Callers injecting the interface access them on the concrete object or check via instanceof.

Local (Unix Permissions)

php
$fs->getVisibility('uploads/report.pdf');   // 'public' or 'private'
$fs->setVisibility('uploads/report.pdf', 'public');
$fs->setVisibility('uploads/secret.pdf', 'private');

"Public" and "private" are mapped to configurable permission values (default: 0644/0600 for files, 0755/0700 for directories).

S3 (ACLs)

php
$s3->setVisibility('public/image.jpg', 'public');   // x-amz-acl: public-read
$s3->setVisibility('private/data.json', 'private'); // x-amz-acl: private
$s3->getVisibility('public/image.jpg');              // 'public'

Configuration

Local Configuration

php
use JardisAdapter\Filesystem\Config\LocalConfig;

$config = new LocalConfig(
    root: '/var/app/storage',       // Required — validated via realpath()
    filePermissions: 0644,          // chmod on write/copy
    dirPermissions: 0755,           // chmod on mkdir
    followSymlinks: true,           // false: exists() returns false for symlinks
    publicFilePerms: 0644,          // getVisibility comparison value
    privateFilePerms: 0600,
    publicDirPerms: 0755,
    privateDirPerms: 0700,
);

$fs = (new FilesystemService())->create($config);

S3 Configuration

php
use JardisAdapter\Filesystem\Config\S3Config;

$config = new S3Config(
    bucket: 'my-bucket',
    region: 'eu-central-1',
    key: 'AKIAEXAMPLE',
    secret: 'wJalrXUtnFEMI...',              // #[\SensitiveParameter]
    endpoint: 'https://s3.amazonaws.com',     // Custom for MinIO etc.
    prefix: 'uploads/',                       // Prepended to every key
);

The secret is masked in __debugInfo() and does not appear in stack traces thanks to #[\SensitiveParameter].

Security

ThreatProtection
Path traversal (../)PathNormalizer detects and blocks .. segments
Null byte injectionPathNormalizer blocks null bytes in the path
Symlink escapeLocalFullPath checks via realpath() that the path stays within root
Root misconfigurationLocalConfig throws exception on invalid root
Bucket wipeS3DeleteDirectory prevents empty prefixes
XXE in S3 XMLLIBXML_NONET on all XML parsers
Secret leakage#[\SensitiveParameter] + __debugInfo() masking
php
// Blocked:
$fs->read('../../../etc/passwd');
// → FilesystemException('Path traversal detected')

$fs->read("foo\x00bar.txt");
// → FilesystemException('Path contains null byte')

Error Handling

ExceptionCause
FilesystemExceptionBase, path traversal, null byte, invalid config
FileNotFoundExceptionFile/directory does not exist
FileExistsExceptionTarget already exists
UnableToReadExceptionRead error (permissions, S3 auth)
UnableToWriteExceptionWrite error (permissions, disk full, S3)
UnableToDeleteExceptionDelete error

Architecture

The package follows the Closure-Orchestrator-Pattern, the Filesystem orchestrator binds handlers as closures:

FilesystemService                      ← Factory
└── Filesystem                         ← Orchestrator
    ├── PathNormalizer                 ← Security (shared)
    ├── Local/                         ← Local handlers
    │   ├── LocalFullPath              ← Symlink check
    │   ├── LocalRead / LocalReadStream
    │   ├── LocalWrite / LocalWriteStream
    │   ├── LocalExists / LocalSize / LocalMimeType
    │   ├── LocalListContents
    │   ├── LocalCopy / LocalMove / LocalDelete
    │   └── LocalGetVisibility / LocalSetVisibility
    └── S3/                            ← S3 handlers
        ├── S3Signer                   ← AWS Signature v4
        ├── S3Request                  ← cURL transport
        ├── S3Read / S3ReadStream
        ├── S3Write / S3WriteStream
        ├── S3Exists / S3Size / S3MimeType
        ├── S3ListContents             ← Paginated
        ├── S3Copy / S3Move / S3Delete
        └── S3GetVisibility / S3SetVisibility

Directory Structure

src/
├── FilesystemService.php           ← Factory
├── Filesystem.php                  ← Orchestrator
├── Config/
│   ├── LocalConfig.php
│   └── S3Config.php
├── Data/
│   └── FileInfo.php                ← DTO for listContents
├── Exception/
│   ├── FilesystemException.php
│   ├── FileNotFoundException.php
│   ├── FileExistsException.php
│   ├── UnableToReadException.php
│   ├── UnableToWriteException.php
│   └── UnableToDeleteException.php
└── Handler/
    ├── PathNormalizer.php
    ├── S3Signer.php
    ├── Local/
    │   ├── LocalResolvePath.php
    │   ├── LocalFullPath.php
    │   ├── LocalRead.php
    │   ├── LocalReadStream.php
    │   └── ... (18 handlers)
    └── S3/
        ├── S3BuildKey.php
        ├── S3Request.php
        ├── S3Read.php
        ├── S3ReadStream.php
        └── ... (19 handlers)

API Reference

FilesystemService

MethodSignatureDescription
locallocal(string $root): FilesystemInterfaceLocal filesystem
s3s3(string $bucket, string $region, string $key, string $secret, string $endpoint = '...', string $prefix = ''): FilesystemInterfaceS3 storage
createcreate(LocalConfig|S3Config $config): FilesystemInterfaceWith explicit config

Filesystem (Both Backends)

MethodSignatureDescription
readread(string $path): stringRead file
readStreamreadStream(string $path): resourceRead as stream
writewrite(string $path, string $content): voidWrite file
writeStreamwriteStream(string $path, $resource): voidWrite stream
existsexists(string $path): boolCheck existence
sizesize(string $path): intFile size (bytes)
lastModifiedlastModified(string $path): intLast modified (timestamp)
mimeTypemimeType(string $path): stringMIME type
listContentslistContents(string $path, bool $recursive = false): iterableList directory
deletedelete(string $path): voidDelete file
copycopy(string $source, string $destination): voidCopy
movemove(string $source, string $destination): voidMove
createDirectorycreateDirectory(string $path): voidCreate directory
deleteDirectorydeleteDirectory(string $path): voidDelete directory recursively
getVisibilitygetVisibility(string $path): string'public' or 'private'
setVisibilitysetVisibility(string $path, string $visibility): voidSet visibility

ENV Configuration

The package defines optional ENV variables for configuration via the Kernel ENV packer:

Local Adapter:

VariableDescriptionDefault
FS_DRIVERlocal or s3local
FS_LOCAL_ROOTRoot directory (required for local)
FS_LOCAL_PERMISSIONS_FILEFile permissions0644
FS_LOCAL_PERMISSIONS_DIRDirectory permissions0755

S3 Adapter:

VariableDescriptionDefault
FS_S3_BUCKETS3 bucket name
FS_S3_REGIONAWS region
FS_S3_KEYAccess Key ID
FS_S3_SECRETSecret Access Key
FS_S3_ENDPOINTEndpoint (for MinIO/DO Spaces)https://s3.amazonaws.com
FS_S3_PREFIXPath prefix in bucket

Complete Example

Upload service with local storage and S3 backup:

php
use JardisAdapter\Filesystem\FilesystemService;

$service = new FilesystemService();

// Local upload storage
$uploads = $service->local($_ENV['FS_LOCAL_ROOT'] ?? '/var/app/storage/uploads');

// S3 backup
$backups = $service->s3(
    bucket: $_ENV['FS_S3_BUCKET'],
    region: $_ENV['FS_S3_REGION'],
    key: $_ENV['FS_S3_KEY'],
    secret: $_ENV['FS_S3_SECRET'],
    prefix: 'app-backups/',
);

// Upload file
$path = 'invoices/2025/' . $invoiceId . '.pdf';
$uploads->write($path, $pdfContent);

// Back up to S3
$backups->write($path, $uploads->read($path));

// Stream-based for large files
$stream = $uploads->readStream('exports/large-report.csv');
$backups->writeStream('exports/large-report.csv', $stream);
fclose($stream);

// List directory
foreach ($uploads->listContents('invoices/2025', recursive: true) as $file) {
    if ($file->isFile()) {
        echo sprintf("%s — %d KB\n", $file->path(), $file->size() / 1024);
    }
}

// Clean up old files
$cutoff = time() - (90 * 86400);  // 90 days
foreach ($uploads->listContents('temp', recursive: true) as $file) {
    if ($file->isFile() && $file->lastModified() < $cutoff) {
        $uploads->delete($file->path());
    }
}

// Visibility
$uploads->setVisibility('public/logo.png', 'public');
$uploads->setVisibility('invoices/2025/INV-001.pdf', 'private');