Skip to content

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, schreibende FilesystemWriterInterface; FilesystemInterface kombiniert beide
  • Stream-SupportreadStream() und writeStream() 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

bash
composer require jardisadapter/filesystem

GitHub: jardisAdapter/filesystem

Erforderliche PHP-Extensions:

ExtensionFür
ext-curlS3-Kommunikation
ext-fileinfoMIME-Type-Erkennung
ext-simplexmlS3-Response-Parsing

Grundlegende Nutzung

Lokales Dateisystem

php
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');  // Rekursiv

S3-kompatibler Storage

php
$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');  // 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 für alle Keys
);

Streams

Für große Dateien, die nicht komplett in den Speicher passen:

php
// 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

php
// 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)

php
$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)

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'

Konfiguration

Lokale Konfiguration

php
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

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

BedrohungSchutz
Path-Traversal (../)PathNormalizer erkennt und blockiert ..-Segmente
Null-Byte-InjectionPathNormalizer blockiert Null-Bytes im Pfad
Symlink-EscapeLocalFullPath prüft per realpath(), ob der Pfad im Root bleibt
Root-FehlkonfigurationLocalConfig wirft Exception bei ungültigem Root
Bucket-WipeS3DeleteDirectory verhindert leere Prefixes
XXE in S3-XMLLIBXML_NONET bei allen XML-Parsern
Secret-Leakage#[\SensitiveParameter] + __debugInfo() Maskierung
php
// Wird blockiert:
$fs->read('../../../etc/passwd');
// → FilesystemException('Path traversal detected')

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

Fehlerbehandlung

ExceptionUrsache
FilesystemExceptionBasis, Path-Traversal, Null-Byte, ungültige Config
FileNotFoundExceptionDatei/Verzeichnis existiert nicht
FileExistsExceptionZiel existiert bereits
UnableToReadExceptionLesefehler (Permissions, S3-Auth)
UnableToWriteExceptionSchreibfehler (Permissions, Disk voll, S3)
UnableToDeleteExceptionLö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 / S3SetVisibility

Verzeichnisstruktur

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

MethodeSignaturBeschreibung
locallocal(string $root): FilesystemInterfaceLokales Dateisystem
s3s3(string $bucket, string $region, string $key, string $secret, string $endpoint = '...', string $prefix = ''): FilesystemInterfaceS3-Storage
createcreate(LocalConfig|S3Config $config): FilesystemInterfaceMit expliziter Config

Filesystem (beide Backends)

MethodeSignaturBeschreibung
readread(string $path): stringDatei lesen
readStreamreadStream(string $path): resourceStream lesen
writewrite(string $path, string $content): voidDatei schreiben
writeStreamwriteStream(string $path, $resource): voidStream schreiben
existsexists(string $path): boolExistenz prüfen
sizesize(string $path): intDateigröße (Bytes)
lastModifiedlastModified(string $path): intLetzte Änderung (Timestamp)
mimeTypemimeType(string $path): stringMIME-Typ
listContentslistContents(string $path, bool $recursive = false): iterableVerzeichnis auflisten
deletedelete(string $path): voidDatei löschen
copycopy(string $source, string $destination): voidKopieren
movemove(string $source, string $destination): voidVerschieben
createDirectorycreateDirectory(string $path): voidVerzeichnis erstellen
deleteDirectorydeleteDirectory(string $path): voidVerzeichnis rekursiv löschen
getVisibilitygetVisibility(string $path): string'public' oder 'private'
setVisibilitysetVisibility(string $path, string $visibility): voidSichtbarkeit setzen

ENV-Konfiguration

Das Package definiert optionale ENV-Variablen für die Konfiguration via Kernel-ENV-Packer:

Lokaler Adapter:

VariableBeschreibungStandard
FS_DRIVERlocal oder s3local
FS_LOCAL_ROOTRoot-Verzeichnis (Pflicht bei local)
FS_LOCAL_PERMISSIONS_FILEFile-Permissions0644
FS_LOCAL_PERMISSIONS_DIRDirectory-Permissions0755

S3-Adapter:

VariableBeschreibungStandard
FS_S3_BUCKETS3 Bucket Name
FS_S3_REGIONAWS Region
FS_S3_KEYAccess Key ID
FS_S3_SECRETSecret Access Key
FS_S3_ENDPOINTEndpoint (für MinIO/DO Spaces)https://s3.amazonaws.com
FS_S3_PREFIXPfad-Prefix im Bucket

Vollständiges Beispiel

Upload-Service mit lokalem Storage und S3-Backup:

php
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');