Filesystem
Lokales Dateisystem und S3 mit einer API: ohne AWS SDK, ohne Flysystem.
Einführung
Dateisystem-Abstraktion in PHP bedeutet meistens: Flysystem installieren, den AWS SDK dazuladen und hoffen, dass die 200+ Klassen zusammenspielen. Für eine einfache Aufgabe (Dateien lesen, schreiben, kopieren, auflisten) ein beträchtlicher Overhead.
jardisadapter/filesystem ersetzt beides. Eine schlanke Abstraktion für lokales Dateisystem und S3-kompatiblen Object Storage (AWS S3, MinIO, DigitalOcean Spaces), implementiert mit purem PHP und cURL:
- Kein AWS SDK, kein Flysystem — S3-Kommunikation direkt mit cURL und AWS Signature Version 4
- Eine API für beide Backends — identischer PHP-Code funktioniert gegen lokale Festplatte und S3
- Reader/Writer-Contracts — read-only Consumer injizieren
FilesystemReaderInterface, schreibendeFilesystemWriterInterface;FilesystemInterfacekombiniert beide - Stream-Support —
readStream()undwriteStream()für große Dateien ohne Memory-Overhead - Security by Default — Path-Traversal-Schutz, Null-Byte-Rejection, Symlink-Escape-Detection, XXE-Schutz bei S3-XML-Parsing
- Visibility — Unix-Permissions (lokal) und ACLs (S3) über
getVisibility()/setVisibility() - Mehrere Instanzen — lokaler Upload-Storage und S3-Backup im selben Prozess mit unterschiedlicher Konfiguration
Installation
composer require jardisadapter/filesystemGitHub: jardisAdapter/filesystem
Erforderliche PHP-Extensions:
| Extension | Für |
|---|---|
ext-curl | S3-Kommunikation |
ext-fileinfo | MIME-Type-Erkennung |
ext-simplexml | S3-Response-Parsing |
Grundlegende Nutzung
Lokales Dateisystem
use JardisAdapter\Filesystem\FilesystemService;
$fs = (new FilesystemService())->local('/var/app/storage');
// Schreiben (Verzeichnisse werden automatisch erstellt)
$fs->write('uploads/report.pdf', $pdfContent);
// Lesen
$content = $fs->read('uploads/report.pdf');
// Prüfen
$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
// Kopieren, Verschieben, Löschen
$fs->copy('uploads/report.pdf', 'archive/report.pdf');
$fs->move('archive/report.pdf', 'archive/2025/report.pdf');
$fs->delete('uploads/report.pdf');
// Verzeichnisse
$fs->createDirectory('exports');
$fs->deleteDirectory('temp'); // RekursivS3-kompatibler Storage
$s3 = (new FilesystemService())->s3(
bucket: 'my-bucket',
region: 'eu-central-1',
key: 'AKIAEXAMPLE',
secret: 'wJalrXUtnFEMI...',
);
// Gleiche API wie lokal
$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 für alle Keys
);Streams
Für große Dateien, die nicht komplett in den Speicher passen:
// Schreiben aus einem Stream
$source = fopen('/tmp/large-export.csv', 'rb');
$fs->writeStream('exports/large.csv', $source);
fclose($source);
// Lesen als Stream
$stream = $fs->readStream('exports/large.csv');
while (!feof($stream)) {
$chunk = fread($stream, 8192);
// Chunk verarbeiten
}
fclose($stream);S3 Write-Stream
Bei S3 wird der Stream vor dem Upload in den Speicher gelesen. Für sehr große Dateien (> 100 MB) sollte Multipart-Upload verwendet werden (noch nicht implementiert).
Verzeichnisse auflisten
// Flach — nur direkte Kinder
foreach ($fs->listContents('uploads') as $item) {
$item->path(); // 'uploads/report.pdf'
$item->size(); // Bytes (0 für Verzeichnisse)
$item->lastModified(); // Unix-Timestamp
$item->isFile(); // true
$item->isDirectory(); // false
}
// Rekursiv — gesamter Baum
foreach ($fs->listContents('', recursive: true) as $item) {
echo $item->path() . "\n";
}S3-Listings werden automatisch paginiert: auch bei tausenden Objekten werden alle Seiten transparent durchlaufen.
Visibility
getVisibility()/setVisibility() sind nicht Teil von FilesystemInterface (auch nicht von Reader/Writer): sie liegen nur am konkreten Filesystem-Objekt. Wer gegen das Interface injiziert, greift über das konkrete Objekt zu oder prüft per instanceof.
Lokal (Unix-Permissions)
$fs->getVisibility('uploads/report.pdf'); // 'public' oder 'private'
$fs->setVisibility('uploads/report.pdf', 'public');
$fs->setVisibility('uploads/secret.pdf', 'private');"Public" und "Private" werden auf konfigurierbare Permission-Werte gemappt (Standard: 0644/0600 für Dateien, 0755/0700 für Verzeichnisse).
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'Konfiguration
Lokale Konfiguration
use JardisAdapter\Filesystem\Config\LocalConfig;
$config = new LocalConfig(
root: '/var/app/storage', // Pflicht — wird per realpath() validiert
filePermissions: 0644, // chmod bei write/copy
dirPermissions: 0755, // chmod bei mkdir
followSymlinks: true, // false: exists() gibt false für Symlinks
publicFilePerms: 0644, // getVisibility-Vergleichswert
privateFilePerms: 0600,
publicDirPerms: 0755,
privateDirPerms: 0700,
);
$fs = (new FilesystemService())->create($config);S3-Konfiguration
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 für MinIO etc.
prefix: 'uploads/', // Wird jedem Key vorangestellt
);Das Secret wird in __debugInfo() maskiert und erscheint dank #[\SensitiveParameter] nicht in Stack-Traces.
Sicherheit
| Bedrohung | Schutz |
|---|---|
Path-Traversal (../) | PathNormalizer erkennt und blockiert ..-Segmente |
| Null-Byte-Injection | PathNormalizer blockiert Null-Bytes im Pfad |
| Symlink-Escape | LocalFullPath prüft per realpath(), ob der Pfad im Root bleibt |
| Root-Fehlkonfiguration | LocalConfig wirft Exception bei ungültigem Root |
| Bucket-Wipe | S3DeleteDirectory verhindert leere Prefixes |
| XXE in S3-XML | LIBXML_NONET bei allen XML-Parsern |
| Secret-Leakage | #[\SensitiveParameter] + __debugInfo() Maskierung |
// Wird blockiert:
$fs->read('../../../etc/passwd');
// → FilesystemException('Path traversal detected')
$fs->read("foo\x00bar.txt");
// → FilesystemException('Path contains null byte')Fehlerbehandlung
| Exception | Ursache |
|---|---|
FilesystemException | Basis, Path-Traversal, Null-Byte, ungültige Config |
FileNotFoundException | Datei/Verzeichnis existiert nicht |
FileExistsException | Ziel existiert bereits |
UnableToReadException | Lesefehler (Permissions, S3-Auth) |
UnableToWriteException | Schreibfehler (Permissions, Disk voll, S3) |
UnableToDeleteException | Löschfehler |
Architektur
Das Package folgt dem Closure-Orchestrator-Pattern, der Filesystem-Orchestrator bindet Handler als Closures:
FilesystemService ← Factory
└── Filesystem ← Orchestrator
├── PathNormalizer ← Sicherheit (shared)
├── Local/ ← Lokale Handler
│ ├── LocalFullPath ← Symlink-Check
│ ├── LocalRead / LocalReadStream
│ ├── LocalWrite / LocalWriteStream
│ ├── LocalExists / LocalSize / LocalMimeType
│ ├── LocalListContents
│ ├── LocalCopy / LocalMove / LocalDelete
│ └── LocalGetVisibility / LocalSetVisibility
└── S3/ ← S3-Handler
├── S3Signer ← AWS Signature v4
├── S3Request ← cURL Transport
├── S3Read / S3ReadStream
├── S3Write / S3WriteStream
├── S3Exists / S3Size / S3MimeType
├── S3ListContents ← Paginiert
├── S3Copy / S3Move / S3Delete
└── S3GetVisibility / S3SetVisibilityVerzeichnisstruktur
src/
├── FilesystemService.php ← Factory
├── Filesystem.php ← Orchestrator
├── Config/
│ ├── LocalConfig.php
│ └── S3Config.php
├── Data/
│ └── FileInfo.php ← DTO für 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 Handler)
└── S3/
├── S3BuildKey.php
├── S3Request.php
├── S3Read.php
├── S3ReadStream.php
└── ... (19 Handler)API-Referenz
FilesystemService
| Methode | Signatur | Beschreibung |
|---|---|---|
local | local(string $root): FilesystemInterface | Lokales Dateisystem |
s3 | s3(string $bucket, string $region, string $key, string $secret, string $endpoint = '...', string $prefix = ''): FilesystemInterface | S3-Storage |
create | create(LocalConfig|S3Config $config): FilesystemInterface | Mit expliziter Config |
Filesystem (beide Backends)
| Methode | Signatur | Beschreibung |
|---|---|---|
read | read(string $path): string | Datei lesen |
readStream | readStream(string $path): resource | Stream lesen |
write | write(string $path, string $content): void | Datei schreiben |
writeStream | writeStream(string $path, $resource): void | Stream schreiben |
exists | exists(string $path): bool | Existenz prüfen |
size | size(string $path): int | Dateigröße (Bytes) |
lastModified | lastModified(string $path): int | Letzte Änderung (Timestamp) |
mimeType | mimeType(string $path): string | MIME-Typ |
listContents | listContents(string $path, bool $recursive = false): iterable | Verzeichnis auflisten |
delete | delete(string $path): void | Datei löschen |
copy | copy(string $source, string $destination): void | Kopieren |
move | move(string $source, string $destination): void | Verschieben |
createDirectory | createDirectory(string $path): void | Verzeichnis erstellen |
deleteDirectory | deleteDirectory(string $path): void | Verzeichnis rekursiv löschen |
getVisibility | getVisibility(string $path): string | 'public' oder 'private' |
setVisibility | setVisibility(string $path, string $visibility): void | Sichtbarkeit setzen |
ENV-Konfiguration
Das Package definiert optionale ENV-Variablen für die Konfiguration via Kernel-ENV-Packer:
Lokaler Adapter:
| Variable | Beschreibung | Standard |
|---|---|---|
FS_DRIVER | local oder s3 | local |
FS_LOCAL_ROOT | Root-Verzeichnis (Pflicht bei local) | — |
FS_LOCAL_PERMISSIONS_FILE | File-Permissions | 0644 |
FS_LOCAL_PERMISSIONS_DIR | Directory-Permissions | 0755 |
S3-Adapter:
| Variable | Beschreibung | Standard |
|---|---|---|
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 (für MinIO/DO Spaces) | https://s3.amazonaws.com |
FS_S3_PREFIX | Pfad-Prefix im Bucket | — |
Vollständiges Beispiel
Upload-Service mit lokalem Storage und S3-Backup:
use JardisAdapter\Filesystem\FilesystemService;
$service = new FilesystemService();
// Lokaler 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/',
);
// Datei hochladen
$path = 'invoices/2025/' . $invoiceId . '.pdf';
$uploads->write($path, $pdfContent);
// In S3 sichern
$backups->write($path, $uploads->read($path));
// Stream-basiert für große Dateien
$stream = $uploads->readStream('exports/large-report.csv');
$backups->writeStream('exports/large-report.csv', $stream);
fclose($stream);
// Verzeichnis auflisten
foreach ($uploads->listContents('invoices/2025', recursive: true) as $file) {
if ($file->isFile()) {
echo sprintf("%s — %d KB\n", $file->path(), $file->size() / 1024);
}
}
// Alte Dateien aufräumen
$cutoff = time() - (90 * 86400); // 90 Tage
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');