Eigene Stream Wrapper in PHP registrieren: stream_wrapper_register im Detail
AI generated
8.4
PHP · Streams
Eigene Stream Wrapper in PHP registrieren
Wie fopen('s3://bucket/key') und Konsorten wirklich funktionieren, und wie man das für eigene Zwecke nutzt

Dass fopen, file_get_contents und file_put_contents nicht nur mit lokalen Pfaden, sondern auch mit Präfixen wie http:// oder php:// umgehen können, liegt an einem einzigen Mechanismus: der Stream-Wrapper-Registry. Wer versteht, wie stream_wrapper_register funktioniert und welche Methoden eine Wrapper-Klasse implementieren muss, kann eigene Protokolle registrieren, sei es für einen Cloud-Speicher oder ein In-Memory-Dateisystem, das Dateizugriff in Unit-Tests ohne echte Festplatte testbar macht.

12 Min. Lesezeit stream_wrapper_register Testbarer Dateizugriff

1. Was ein Stream Wrapper ist und wie PHP Protokoll-Präfixe auflöst

Jeder Aufruf von fopen, file_get_contents oder file_put_contents beginnt intern mit demselben Schritt: PHP untersucht den übergebenen Pfad auf ein Protokoll-Präfix der Form protokoll://. Findet sich dort ein Doppelpunkt gefolgt von zwei Schrägstrichen, schlägt PHP in einer internen Registry nach, welche Klasse für dieses Protokoll zuständig ist, und delegiert sämtliche Dateisystem-Operationen an diese Klasse, statt sie selbst über das native Betriebssystem-Dateisystem auszuführen. Fehlt ein Präfix, greift implizit der eingebaute file://-Wrapper, der genau dieses native Verhalten liefert.

Diese Registry ist von Anfang an erweiterbar konzipiert: Neben den eingebauten Wrappern für http://, php://, zlib:// oder data:// lässt sich mit stream_wrapper_register jederzeit ein eigenes Protokoll eintragen, dessen Implementierung vollständig im Userland liegt. Sobald die Registrierung erfolgt ist, funktionieren sämtliche Standardfunktionen wie fopen, fread, fwrite, is_dir oder unlink transparent mit dem neuen Protokoll, ohne dass der aufrufende Code wissen muss, dass hinter dem Präfix keine echte Festplatte, sondern eigener PHP-Code steckt.

2. Die Signatur von stream_wrapper_register und ihre Einschränkungen

Die Funktion erwartet drei Parameter: den Protokoll-Namen als String ohne die Zeichen ://, den vollqualifizierten Klassennamen der Wrapper-Implementierung sowie optional ein Flag-Bitmask, das etwa mit STREAM_IS_URL signalisiert, dass es sich um eine URL-basierte Ressource handelt. Ein zentraler Stolperstein dabei betrifft den Konstruktor der Wrapper-Klasse: PHP instanziiert diese Klasse intern selbst, ruft dabei aber niemals einen benutzerdefinierten Konstruktor mit Parametern auf, sodass jede Konfiguration nicht über den Konstruktor, sondern über statische Eigenschaften, eine Dependency-Injection-Registry oder den Context-Mechanismus erfolgen muss.

Ein zweiter Punkt betrifft die Registrierung selbst: Ein bereits vergebener Protokollname, etwa http, lässt sich nicht einfach überschreiben, ohne ihn zuvor explizit mit stream_wrapper_unregister freizugeben, und ein Versuch, denselben eigenen Namen doppelt zu registrieren, löst eine Warnung aus und liefert false zurück. In produktivem Code sollte die Registrierung daher stets defensiv erfolgen, etwa durch eine vorherige Prüfung mit in_array gegen stream_get_wrappers.


<?php

declare(strict_types=1);

/**
 * Registriert den memfs:// Wrapper defensiv, falls er noch nicht
 * vorhanden ist, um doppelte Registrierung zu vermeiden.
 *
 * @return void
 */
function registerMemfsWrapper(): void
{
    if (in_array('memfs', stream_get_wrappers(), true)) {
        return;
    }

    stream_wrapper_register('memfs', InMemoryStreamWrapper::class);
}

registerMemfsWrapper();

3. Pflicht- und optionale Methoden der Wrapper-Konvention

Anders als bei einem klassischen Interface prüft PHP die Wrapper-Klasse nicht gegen ein deklariertes Interface, sondern ruft schlicht Methoden mit einem festgelegten Namen und einer festgelegten Signatur auf, sofern sie existieren. Für lesenden Dateizugriff sind stream_open zum Öffnen, stream_read zum Lesen einzelner Chunks, stream_eof zur Prüfung auf Dateiende sowie stream_close zum Schließen praktisch unverzichtbar, denn ohne sie schlägt bereits ein einfaches fopen mit anschließendem fread fehl.

Für schreibenden Zugriff kommt stream_write hinzu, für Verzeichnis-Operationen url_stat, dir_opendir, dir_readdir und dir_closedir, und für Metadaten wie Dateigröße oder Änderungszeitpunkt die Methode stream_stat, die einen Array im Format von PHPs eingebautem stat zurückgeben muss. Implementiert eine Wrapper-Klasse eine dieser Methoden nicht, meldet PHP beim Aufruf der entsprechenden Standardfunktion schlicht einen Fehler, denn die fehlende Methode wird als 'nicht unterstützte Operation' interpretiert.

4. stream_stat und url_stat: Metadaten korrekt zurückgeben

stream_stat und url_stat verwenden beide dasselbe Rückgabeformat wie PHPs eingebaute Funktion stat, also ein Array mit den klassischen 13 numerisch indizierten Feldern sowie zusätzlich denselben Werten unter sprechenden String-Schlüsseln wie size, mtime oder mode. Der Unterschied zwischen beiden Methoden liegt im Zeitpunkt des Aufrufs: stream_stat wird auf einer bereits über stream_open geöffneten Ressource aufgerufen, während url_stat direkt auf einem Pfad arbeitet, ganz ohne vorheriges Öffnen, etwa wenn Funktionen wie is_file, file_exists oder filesize aufgerufen werden.

Fehlt url_stat in der Wrapper-Klasse, melden genau diese Funktionen fälschlich, die Datei existiere nicht, selbst wenn stream_open sie anschließend erfolgreich öffnen könnte, denn PHP fragt für reine Existenzprüfungen zuerst gezielt url_stat ab, statt eine vollständige Ressource zu öffnen. Das Feld mode ist dabei besonders wichtig, denn ein fehlender oder falscher Verzeichnis-Bit-Wert dort lässt Funktionen wie is_dir fälschlich false zurückgeben, obwohl der Wrapper das jeweilige virtuelle Verzeichnis eigentlich korrekt verwaltet.


<?php

declare(strict_types=1);

/**
 * Liefert Metadaten für einen Pfad, ohne die Ressource vorher über
 * stream_open zu öffnen, z.B. für is_file() oder file_exists().
 *
 * @param string $path Vollständiger Pfad inklusive memfs:// Präfix
 * @param int $flags Bitmask mit STREAM_URL_STAT_ Flags
 * @return array<int|string, int>|false Stat-Array oder false, wenn unbekannt
 */
public function url_stat(string $path, int $flags): array|false
{
    if (!isset(self::$files[$path])) {
        return false;
    }

    $size = strlen(self::$files[$path]);

    return [
        'dev' => 0, 'ino' => 0, 'mode' => 0100644, 'nlink' => 1,
        'uid' => 0, 'gid' => 0, 'rdev' => 0, 'size' => $size,
        'atime' => time(), 'mtime' => time(), 'ctime' => time(),
        'blksize' => -1, 'blocks' => -1,
    ];
}

5. Beispiel: Ein In-Memory-Dateisystem für Unit-Tests

Der praktisch wertvollste Anwendungsfall für einen selbstgeschriebenen Stream Wrapper in einer normalen Anwendung ist ein In-Memory-Dateisystem für Tests. Code, der intern mit fopen, fwrite oder file_get_contents gegen einen konfigurierbaren Pfad arbeitet, lässt sich damit in Unit-Tests vollständig ohne echten Festplattenzugriff testen, indem der Pfad im Test auf ein memfs://-Präfix umgestellt wird, während er in Produktion weiterhin auf ein echtes Verzeichnis zeigt.

Im Kern hält eine solche Wrapper-Klasse den Dateiinhalt einfach in einer statischen Array-Eigenschaft, adressiert über den Pfad als Schlüssel, und ein interner Lesezeiger-Offset simuliert die Position innerhalb der Datei. Der folgende Ausschnitt zeigt die wichtigsten Methoden einer minimalen, aber funktionsfähigen Implementierung, die sowohl Lesen als auch Schreiben unterstützt.


<?php

declare(strict_types=1);

/**
 * Minimaler In-Memory Stream Wrapper für das Protokoll memfs://, gedacht
 * für Unit-Tests ohne echten Dateisystemzugriff.
 */
final class InMemoryStreamWrapper
{
    /** @var array<string, string> Dateiinhalte, indiziert nach Pfad */
    private static array $files = [];

    private string $path = '';
    private int $position = 0;

    /**
     * Öffnet eine In-Memory-Datei für Lese- oder Schreibzugriff.
     *
     * @param string $path Vollständiger Pfad inklusive memfs:// Präfix
     * @param string $mode Der übergebene fopen-Modus, z.B. "r" oder "w"
     * @param int $options Bitmask mit STREAM_ Flags
     * @param string|null $openedPath Referenz auf den tatsächlich geöffneten Pfad
     * @return bool True, wenn das Oeffnen erfolgreich war
     */
    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        $this->path = $path;

        if (str_starts_with($mode, 'w')) {
            self::$files[$path] = '';
        }

        return isset(self::$files[$path]) || str_starts_with($mode, 'w');
    }

    /**
     * Liest bis zu $count Byte ab der aktuellen Leseposition.
     *
     * @param int $count Maximale Anzahl zu lesender Bytes
     * @return string Der gelesene Inhalt
     */
    public function stream_read(int $count): string
    {
        $chunk = substr(self::$files[$this->path] ?? '', $this->position, $count);
        $this->position += strlen($chunk);

        return $chunk;
    }

    /**
     * Hängt Daten an der aktuellen Position an den Dateiinhalt an.
     *
     * @param string $data Die zu schreibenden Daten
     * @return int Anzahl der tatsächlich geschriebenen Bytes
     */
    public function stream_write(string $data): int
    {
        self::$files[$this->path] = (self::$files[$this->path] ?? '') . $data;
        $this->position += strlen($data);

        return strlen($data);
    }

    /**
     * Prüft, ob das Ende der In-Memory-Datei erreicht ist.
     *
     * @return bool True am Dateiende
     */
    public function stream_eof(): bool
    {
        return $this->position >= strlen(self::$files[$this->path] ?? '');
    }
}

6. Grundgerüst für ein S3-artiges Protokoll mit Context-Optionen

Für ein Protokoll wie s3://bucket/key reicht ein reiner In-Memory-Speicher nicht aus, denn stream_open muss stattdessen tatsächlich einen HTTP-Request gegen die S3-API absetzen. Damit die Wrapper-Klasse dabei an Zugangsdaten wie Access-Key und Secret-Key gelangt, ohne diese in einem parameterlosen Konstruktor unterbringen zu können, kommt der Context-Mechanismus ins Spiel: stream_context_create nimmt einen Optionen-Array entgegen, und innerhalb der Wrapper-Klasse liefert die von PHP automatisch gesetzte Eigenschaft $context Zugriff auf genau diese Optionen über stream_context_get_options.

Damit lässt sich der eigentliche Netzwerkzugriff sauber von der Wrapper-Logik trennen: stream_open liest die Zugangsdaten aus dem Context, baut daraus eine signierte Anfrage und initialisiert intern etwa einen Puffer mit der heruntergeladenen Antwort, während stream_read anschließend nur noch aus diesem bereits geladenen Puffer liest. Diese Trennung hält die Wrapper-Klasse testbar, denn der eigentliche HTTP-Client lässt sich per Dependency Injection austauschen, auch wenn die Wrapper-Instanz selbst von PHP ohne Konstruktor-Parameter erzeugt wird.


<?php

declare(strict_types=1);

$context = stream_context_create([
    's3' => [
        'access_key' => getenv('S3_ACCESS_KEY'),
        'secret_key' => getenv('S3_SECRET_KEY'),
        'region' => 'eu-central-1',
    ],
]);

$handle = fopen('s3://invoices-bucket/2026/rechnung-4711.pdf', 'r', false, $context);

7. Testbarkeit von Dateizugriff durch eigene Wrapper verbessern

Der eigentliche Gewinn eines eigenen Stream Wrappers liegt selten im Ersatz für eine ausgereifte Cloud-SDK, sondern in der Fähigkeit, Code, der fest mit fopen, file_put_contents oder SplFileObject gegen einen Pfad arbeitet, ohne Umbau testbar zu machen. Statt jede Klasse, die Dateien schreibt, um eine austauschbare Filesystem-Abstraktion herumzubauen, genügt es oft, den Basis-Pfad konfigurierbar zu machen und in Tests einen memfs://-Präfix einzusetzen.

Wichtig ist dabei, den Wrapper vor jedem Test explizit zurückzusetzen, etwa indem die statische Array-Eigenschaft in einer setUp-Methode geleert wird, denn andernfalls können Tests über gemeinsam genutzten statischen Zustand unbeabsichtigt voneinander abhängen. Alternativen wie die Bibliothek mikey179/vfsstream lösen dasselbe Problem bereits fertig implementiert und mit deutlich mehr Funktionsumfang, etwa Berechtigungssimulation, sind für einfache Fälle aber oft mehr, als tatsächlich gebraucht wird.

8. Fallstricke: Konflikte mit include, Performance und Fehlerbehandlung

Ein eigener Wrapper funktioniert grundsätzlich auch mit include und require, sofern das Präfix genutzt wird, was in Kombination mit dynamischem Code aus einer Datenbank oder einem Netzwerk-Speicher theoretisch verlockend, aber sicherheitstechnisch heikel ist, denn PHP prüft an dieser Stelle keinerlei Herkunft des Codes. Ebenso sollte ein Wrapper niemals den PHP-eigenen include_path für kritische Systemdateien überschreiben, weil sich sonst schwer nachvollziehbare Fehlerquellen ergeben, wenn Standardbibliotheken plötzlich über einen fremden Wrapper laufen.

Performance ist ein weiterer Punkt: Da jeder Methodenaufruf durch den Userland-PHP-Code der Wrapper-Klasse läuft, statt direkt einen Systemcall auszuführen, ist ein eigener Wrapper grundsätzlich langsamer als der native file://-Wrapper, was bei sehr häufigen kleinen Lesezugriffen spürbar wird. Für Fehlerbehandlung gilt außerdem, dass ein Stream Wrapper keine Exceptions werfen sollte, da PHP diese an dieser Stelle nicht konsistent behandelt, sondern stattdessen trigger_error mit E_USER_WARNING nutzen sollte, damit sich das Verhalten in Fehlerfällen mit dem der eingebauten Wrapper deckt.

9. Wann sich ein eigener Wrapper lohnt, und wann eine bestehende Lösung reicht

Ein selbstgeschriebener Stream Wrapper lohnt sich vor allem dann, wenn bestehender Code untrennbar mit den nativen Dateifunktionen verdrahtet ist und ein Umbau auf eine Filesystem-Abstraktion wie league/flysystem zu aufwendig wäre, oder wenn tatsächlich ein neues, in PHP noch nicht existierendes Protokoll angebunden werden soll. Für reine Testbarkeit ist ein minimaler In-Memory-Wrapper oft schneller selbst geschrieben, als eine externe Abhängigkeit einzuführen, gerade wenn nur wenige Methoden tatsächlich gebraucht werden.

Für produktiven Cloud-Speicherzugriff dagegen lohnt sich meist keine eigene Implementierung: league/flysystem bietet bereits ausgereifte Adapter für S3, Google Cloud Storage und Azure Blob Storage inklusive Retry-Logik, Streaming großer Dateien und konsistenter Fehlerbehandlung, was ein selbstgeschriebener Wrapper erst über viele Iterationen erreichen würde. Der eigene Wrapper bleibt damit primär ein Werkzeug für Testbarkeit, Prototyping und die Integration wirklich exotischer, nicht anderweitig unterstützter Datenquellen.

Methode Zweck Für Lesen nötig? Für Schreiben nötig?
stream_open() Öffnet die Ressource für einen gegebenen Pfad Ja Ja
stream_read() Liest einen Chunk ab der aktuellen Position Ja Nein
stream_write() Schreibt Daten an der aktuellen Position Nein Ja
stream_eof() Prüft, ob das Dateiende erreicht ist Ja Nein
stream_stat() Liefert Metadaten wie Größe und Zeitstempel Empfohlen Empfohlen
stream_close() Schließt die Ressource und gibt Ressourcen frei Ja Ja
url_stat() Liefert Metadaten ohne vorheriges Öffnen (für is_file etc.) Empfohlen Empfohlen

Mironsoft

PHP-Modernisierung, Code-Qualität und Legacy-Refactoring

Gewachsener PHP-Code, der niemand mehr gern anfasst?

Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.

Legacy-Refactoring

Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.

Code-Qualität etablieren

PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.

Versions-Upgrade

PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.

10. Zusammenfassung

Eigene Stream Wrapper: Das Wichtigste auf einen Blick

Protokoll-Registry

PHP löst jeden Pfad mit Präfix über eine interne Registry auf und delegiert an die passende Wrapper-Klasse.

Kein Konstruktor

PHP instanziiert Wrapper-Klassen ohne Parameter, Konfiguration läuft über Context oder statischen Zustand.

Konventions-Methoden

PHP prüft kein Interface, sondern ruft bekannte Methodennamen wie stream_open auf, sofern sie existieren.

Testbarkeit als Kernnutzen

Ein In-Memory-Wrapper macht dateibasierten Code testbar, ohne die Anwendungslogik selbst umzubauen.

11. FAQ: Eigene Stream Wrapper: Das Wichtigste auf einen Blick

1Muss eine Stream-Wrapper-Klasse ein bestimmtes Interface implementieren?
Nein, PHP prüft kein deklariertes Interface, sondern ruft zur Laufzeit Methoden mit festgelegtem Namen wie stream_open auf, sofern die Klasse sie besitzt. Fehlende Methoden führen zu einer 'nicht unterstützt'-Fehlermeldung bei entsprechenden Aufrufen.
2Warum kann man einer Wrapper-Klasse keinen eigenen Konstruktor mit Parametern geben?
PHP instanziiert die Klasse intern selbst und ruft dabei keinen benutzerdefinierten Konstruktor mit Argumenten auf. Konfiguration muss deshalb über den Context-Mechanismus, statische Eigenschaften oder eine externe Registry erfolgen.
3Wie übergibt man Konfigurationsdaten wie Zugangsschlüssel an einen eigenen Wrapper?
Über stream_context_create und den daraus erzeugten Context, den PHP der Wrapper-Instanz automatisch als $context-Eigenschaft zur Verfügung stellt und der über stream_context_get_options ausgelesen werden kann.
4Kann man den eingebauten http:// Wrapper durch einen eigenen ersetzen?
Ja, aber nur nach explizitem stream_wrapper_unregister für http, da PHP bereits vergebene Protokollnamen nicht stillschweigend überschreibt. Das ist selten sinnvoll und kann viele Bibliotheken unerwartet beeinflussen.
5Ist ein eigener Stream Wrapper langsamer als der native Dateizugriff?
Ja, weil jeder Methodenaufruf durch Userland-PHP-Code läuft statt direkt einen Systemcall auszuführen. Bei vielen kleinen Lesezugriffen ist der Unterschied messbar, bei wenigen großen Operationen meist vernachlässigbar.
6Sollte ein Stream Wrapper Exceptions werfen, wenn ein Zugriff fehlschlägt?
Nein, besser ist trigger_error mit E_USER_WARNING, da PHP Exceptions in diesem Kontext nicht konsistent wie die eingebauten Wrapper behandelt und aufrufender Code oft auf den klassischen Warnungs-Mechanismus vorbereitet ist.
7Funktioniert include mit einem eigenen Protokoll-Präfix?
Grundsätzlich ja, sofern das Präfix im include-Aufruf genutzt wird, das ist aber sicherheitstechnisch riskant, weil PHP dabei keine Herkunftsprüfung des geladenen Codes vornimmt.
8Wie setzt man einen In-Memory-Wrapper zwischen Testfällen sauber zurück?
Am einfachsten, indem die statische Array-Eigenschaft, die den Dateiinhalt hält, in der setUp-Methode des Testfalls explizit geleert wird, damit Tests nicht über gemeinsamen statischen Zustand voneinander abhängen.
9Lohnt sich ein eigener Wrapper für produktiven S3-Zugriff?
Meist nicht. Bibliotheken wie league/flysystem bieten bereits ausgereifte S3-Adapter mit Retry-Logik und Streaming, die ein selbstgeschriebener Wrapper erst nach vielen Iterationen erreichen würde.
10Kann ein Stream Wrapper auch Verzeichnisoperationen wie readdir unterstützen?
Ja, über die Methoden dir_opendir, dir_readdir und dir_closedir, die zusammen ein virtuelles Verzeichnis simulieren und von Funktionen wie opendir oder scandir genutzt werden, sobald das Protokoll-Präfix erkannt wird.