Zum Hauptinhalt springen
Schneespur
Dokumentation durchblättern

Module entwickeln

Speicher & Backup

Wie Module über StorageBackendRegistry und BackupTargetRegistry zusätzliche Speicher- und Backup-Ziele anmelden — mit Fallback-Verhalten, Beispielen und Regeln für sicheren Dateizugriff.

Schneespur speichert Dateien — PDF-Einsatznachweise, Fotos, Dokumente — über austauschbare Speicher-Backends. Der Kern bringt ein lokales Dateisystem-Backend mit; Module ergänzen weitere Ziele wie S3 oder SFTP, ohne dass aufrufender Code sich ändern muss. Genau dasselbe Muster gilt für Backup-Ziele.

Speicher-Backends

Die StorageBackendRegistry verwaltet die Dateiablage. Ein Modul registriert dort ein zusätzliches Backend, und der restliche Code spricht weiterhin nur die Registry an — nicht das konkrete Backend. Dadurch lässt sich der Speicherort wechseln, ohne jeden Aufrufer anzufassen.

Jedes Backend erfüllt dasselbe Interface:

interface StorageBackendInterface
{
    public function slug(): string;
    public function label(): string;
    public function store(string $relativePath, string $contents): void;
    public function retrieve(string $relativePath): ?string;
    public function delete(string $relativePath): bool;
    public function exists(string $relativePath): bool;
    public function url(string $relativePath): string;
    public function isConfigured(): bool;
}

isConfigured() ist bewusst Teil des Vertrags: Ein S3-Backend ohne Zugangsdaten ist nicht einsatzbereit, und die Registry kann das prüfen, bevor sie es als aktives Backend verwendet.

Speicher im eigenen Modul nutzen

Sie lösen das aktive Backend über die Registry auf und schreiben oder lesen relative Pfade:

$storage = app(StorageBackendRegistry::class);

// Write a file
$backend = $storage->resolve(); // active backend
$backend->store('documents/contract-123.pdf', $pdfContent);

// Read with automatic fallback to local
$content = $storage->retrieveWithFallback('documents/contract-123.pdf');

// Get URL with fallback
$url = $storage->urlWithFallback('photos/job-42.jpg');

Fallback-Verhalten

Die StorageBackendRegistry bringt eine eingebaute Rückfall-Logik mit:

  • retrieveWithFallback() — versucht zuerst das aktive Backend und greift auf das lokale zurück, wenn die Datei dort nicht liegt.
  • urlWithFallback() — dasselbe für die URL-Erzeugung.

Das ist vor allem während einer Migration nützlich: Wer von lokalem Speicher auf Cloud-Speicher umstellt, hat eine Übergangszeit, in der ältere Dateien noch lokal liegen und neue bereits in der Cloud. Der Fallback überbrückt diese Phase, ohne dass Sie alle Bestände vorab umkopieren müssen.

Ein Speicher-Backend-Modul bauen

Ein eigenes Backend implementiert das Interface und liest seine Konfiguration aus den Modul-Einstellungen. Das folgende Beispiel zeigt die tragenden Methoden für ein S3-Backend; die übrigen folgen demselben Muster:

namespace Schneespur\Module\S3Storage\Storage;

use App\Models\Setting;
use App\Services\Storage\StorageBackendInterface;
use Aws\S3\S3Client;

class S3StorageBackend implements StorageBackendInterface
{
    public function slug(): string { return 's3'; }
    public function label(): string { return 'Amazon S3'; }

    public function store(string $relativePath, string $contents): void
    {
        $this->client()->putObject([
            'Bucket' => Setting::get('s3.bucket'),
            'Key' => $relativePath,
            'Body' => $contents,
        ]);
    }

    public function retrieve(string $relativePath): ?string
    {
        try {
            $result = $this->client()->getObject([
                'Bucket' => Setting::get('s3.bucket'),
                'Key' => $relativePath,
            ]);
            return (string) $result['Body'];
        } catch (\Throwable) {
            return null;
        }
    }

    public function delete(string $relativePath): bool { /* ... */ }
    public function exists(string $relativePath): bool { /* ... */ }
    public function url(string $relativePath): string { /* ... */ }

    public function isConfigured(): bool
    {
        return !empty(Setting::get('s3.bucket'))
            && !empty(Setting::get('s3.key'))
            && !empty(Setting::get('s3.secret'));
    }

    private function client(): S3Client { /* ... */ }
}

Beachten Sie, dass retrieve() bei einem Fehler null zurückgibt statt eine Ausnahme durchzureichen. Das ist die Voraussetzung dafür, dass retrieveWithFallback() sauber auf das lokale Backend ausweichen kann, statt am Cloud-Fehler abzubrechen.

Die Konfiguration liest das Backend über Setting::get() mit dem Modul-Slug als Präfix (s3.bucket, s3.key, s3.secret). Wie Module Einstellungen registrieren, steht unter ServiceProvider.

Registriert wird das Backend im boot() des ServiceProviders:

app(StorageBackendRegistry::class)->register('s3', S3StorageBackend::class);

Backup-Ziele

Die BackupTargetRegistry bestimmt, wohin Backups geschrieben werden. Der Kern bietet ein lokales Backup-Ziel; Module ergänzen Cloud-Ziele. Das Prinzip ist dasselbe wie bei den Speicher-Backends, der Vertrag ist aber schlanker — ein Backup-Ziel muss nur ablegen und zurückspielen können:

interface BackupTargetInterface
{
    public function slug(): string;
    public function label(): string;
    public function store(string $sourcePath): bool;        // store a backup file
    public function restore(string $identifier, string $destinationPath): bool; // restore from backup
    public function isConfigured(): bool;
}

Ein Backup-Ziel-Modul bauen

namespace Schneespur\Module\CloudBackup\Backup;

use App\Models\Setting;
use App\Services\Backup\BackupTargetInterface;

class S3BackupTarget implements BackupTargetInterface
{
    public function slug(): string { return 's3-backup'; }
    public function label(): string { return 'Amazon S3 Backup'; }

    public function store(string $sourcePath): bool
    {
        // Upload the backup file to S3
        $key = 'backups/' . basename($sourcePath);
        // ... S3 upload logic
        return true;
    }

    public function restore(string $identifier, string $destinationPath): bool
    {
        // Download backup from S3 and write to $destinationPath
        return true;
    }

    public function isConfigured(): bool
    {
        return !empty(Setting::get('s3-backup.bucket'));
    }
}

Registriert wird das Ziel wie gewohnt im boot():

app(BackupTargetRegistry::class)->register('s3-backup', S3BackupTarget::class);

Aktives Ziel

Welches Backup-Ziel aktiv ist, steht in der Einstellung backup_target. Die Administration wählt es auf der Backup-Einstellungsseite aus; die verfügbaren Ziele liefert availableTargets(). So entscheidet der Betrieb über die Oberfläche, wohin Backups gehen — das Modul stellt die Möglichkeit bereit, erzwingt sie aber nicht.

Empfehlungen für den Umgang mit Dateien

Diese Regeln halten Datei-Handling sicher und gegenüber einem Backend-Wechsel robust:

  1. Dateien nie in public/ ablegen — immer über das Speicher-Backend. Was in public/ liegt, ist ohne Zugriffsprüfung im Web erreichbar.
  2. Relative Pfade verwenden — das Backend kümmert sich um absolute Pfade. So bleibt der Code unabhängig davon, ob lokal oder in der Cloud gespeichert wird.
  3. Pfade mit dem Modul-Slug präfixen — z. B. documents/, invoices/. Das hält die Ablage übersichtlich und vermeidet Kollisionen zwischen Modulen.
  4. Dateien über authentifizierte Routen ausliefern — vor dem Download die Berechtigung prüfen.
  5. Metadaten in der Datenbank haltenfile_path, mime_type, file_size. Der Speicher hält die Bytes, die Datenbank das Wissen darüber.

Authentifizierte Download-Route

Eine Datei sollte nie direkt aus dem Speicher ins Web durchgereicht werden, ohne dass geprüft wird, wer sie abrufen darf. Die Route prüft erst die Berechtigung über Gate und liefert den Inhalt dann mit passenden Headern aus:

Route::get('documents/{document}/download', function (Document $document) {
    Gate::authorize('documents.view');

    $storage = app(StorageBackendRegistry::class);
    $content = $storage->retrieveWithFallback($document->file_path);

    if ($content === null) {
        abort(404);
    }

    return response($content)
        ->header('Content-Type', $document->mime_type)
        ->header('Content-Disposition', 'attachment; filename="' . $document->title . '"');
})->name('admin.documents.download');

Wie Module Routen und Berechtigungen anmelden, steht unter Routen & APIs und Berechtigungen & Rollen.