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, writersFilesystemWriterInterface;FilesystemInterfacecombines both - Stream support —
readStream()andwriteStream()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
composer require jardisadapter/filesystemGitHub: jardisAdapter/filesystem
Required PHP extensions:
| Extension | For |
|---|---|
ext-curl | S3 communication |
ext-fileinfo | MIME type detection |
ext-simplexml | S3 response parsing |
Basic Usage
Local Filesystem
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'); // RecursiveS3-Compatible Storage
$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'); // trueMinIO / Custom Endpoint
$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:
// 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
// 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)
$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)
$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
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
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
| Threat | Protection |
|---|---|
Path traversal (../) | PathNormalizer detects and blocks .. segments |
| Null byte injection | PathNormalizer blocks null bytes in the path |
| Symlink escape | LocalFullPath checks via realpath() that the path stays within root |
| Root misconfiguration | LocalConfig throws exception on invalid root |
| Bucket wipe | S3DeleteDirectory prevents empty prefixes |
| XXE in S3 XML | LIBXML_NONET on all XML parsers |
| Secret leakage | #[\SensitiveParameter] + __debugInfo() masking |
// Blocked:
$fs->read('../../../etc/passwd');
// → FilesystemException('Path traversal detected')
$fs->read("foo\x00bar.txt");
// → FilesystemException('Path contains null byte')Error Handling
| Exception | Cause |
|---|---|
FilesystemException | Base, path traversal, null byte, invalid config |
FileNotFoundException | File/directory does not exist |
FileExistsException | Target already exists |
UnableToReadException | Read error (permissions, S3 auth) |
UnableToWriteException | Write error (permissions, disk full, S3) |
UnableToDeleteException | Delete 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 / S3SetVisibilityDirectory 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
| Method | Signature | Description |
|---|---|---|
local | local(string $root): FilesystemInterface | Local filesystem |
s3 | s3(string $bucket, string $region, string $key, string $secret, string $endpoint = '...', string $prefix = ''): FilesystemInterface | S3 storage |
create | create(LocalConfig|S3Config $config): FilesystemInterface | With explicit config |
Filesystem (Both Backends)
| Method | Signature | Description |
|---|---|---|
read | read(string $path): string | Read file |
readStream | readStream(string $path): resource | Read as stream |
write | write(string $path, string $content): void | Write file |
writeStream | writeStream(string $path, $resource): void | Write stream |
exists | exists(string $path): bool | Check existence |
size | size(string $path): int | File size (bytes) |
lastModified | lastModified(string $path): int | Last modified (timestamp) |
mimeType | mimeType(string $path): string | MIME type |
listContents | listContents(string $path, bool $recursive = false): iterable | List directory |
delete | delete(string $path): void | Delete file |
copy | copy(string $source, string $destination): void | Copy |
move | move(string $source, string $destination): void | Move |
createDirectory | createDirectory(string $path): void | Create directory |
deleteDirectory | deleteDirectory(string $path): void | Delete directory recursively |
getVisibility | getVisibility(string $path): string | 'public' or 'private' |
setVisibility | setVisibility(string $path, string $visibility): void | Set visibility |
ENV Configuration
The package defines optional ENV variables for configuration via the Kernel ENV packer:
Local Adapter:
| Variable | Description | Default |
|---|---|---|
FS_DRIVER | local or s3 | local |
FS_LOCAL_ROOT | Root directory (required for local) | — |
FS_LOCAL_PERMISSIONS_FILE | File permissions | 0644 |
FS_LOCAL_PERMISSIONS_DIR | Directory permissions | 0755 |
S3 Adapter:
| Variable | Description | Default |
|---|---|---|
FS_S3_BUCKET | S3 bucket name | — |
FS_S3_REGION | AWS region | — |
FS_S3_KEY | Access Key ID | — |
FS_S3_SECRET | Secret Access Key | — |
FS_S3_ENDPOINT | Endpoint (for MinIO/DO Spaces) | https://s3.amazonaws.com |
FS_S3_PREFIX | Path prefix in bucket | — |
Complete Example
Upload service with local storage and S3 backup:
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');