Skip to main content
Wintertrace
Browse the documentation

Building modules

Storage & backup

Register storage backends and backup targets from a module so its data takes part in the core's storage and backup flow.

The application stores files — PDF job records, photos, documents — through interchangeable storage backends. The core provides a local filesystem backend; modules add further targets such as S3 or SFTP, without any calling code needing to change. The same pattern applies to backup targets.

Storage backends

The StorageBackendRegistry manages file storage. A module registers an additional backend there, and the rest of the code continues to talk only to the registry — not to the concrete backend. This means the storage location can be switched without touching every caller.

Every backend satisfies the same 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() is deliberately part of the contract: an S3 backend without credentials is not ready to use, and the registry can check this before selecting it as the active backend.

Using storage in your module

Resolve the active backend through the registry and work with relative paths:

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

The StorageBackendRegistry has built-in fallback logic:

  • retrieveWithFallback() — tries the active backend first, falls back to local if the file is not found there.
  • urlWithFallback() — the same for URL generation.

This is particularly useful during a migration: when switching from local storage to cloud storage, there is a transitional period in which older files still live locally while new ones go to the cloud. The fallback bridges that gap without requiring a bulk copy of all existing files beforehand.

Building a storage backend module

A custom backend implements the interface and reads its configuration from module settings. The following example shows the key methods for an S3 backend; the remaining methods follow the same pattern:

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 { /* ... */ }
}

Note that retrieve() returns null on failure rather than propagating an exception. This is what allows retrieveWithFallback() to cleanly switch to the local backend instead of aborting on a cloud error.

Configuration is read from Setting::get() with the module slug as a prefix (s3.bucket, s3.key, s3.secret). How modules register settings is covered under ServiceProvider.

Register the backend in your ServiceProvider’s boot():

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

Backup targets

The BackupTargetRegistry determines where backups are written. The core provides a local backup target; modules add cloud targets. The principle is the same as for storage backends, but the contract is slimmer — a backup target only needs to store and restore:

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

Building a backup target module

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

Register the target in the usual way in boot():

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

Active target

Which backup target is active is stored in the backup_target setting. Administrators choose it on the backup settings page; availableTargets() supplies the list of available options. The operator decides via the UI where backups go — the module provides the capability without imposing it.

File handling best practices

These rules keep file handling safe and robust in the face of backend changes:

  1. Never store files in public/ — always use the storage backend. Anything in public/ is reachable from the web without any access check.
  2. Use relative paths — the backend handles absolute paths. This keeps code independent of whether storage is local or in the cloud.
  3. Prefix paths with your module slug — for example documents/, invoices/. This keeps storage organised and prevents collisions between modules.
  4. Serve files through authenticated routes — check permissions before any download.
  5. Store metadata in the databasefile_path, mime_type, file_size. The storage backend holds the bytes; the database holds the knowledge about them.

Authenticated download route

A file should never be passed directly from storage to the web without checking who is allowed to retrieve it. The route checks the permission via Gate first, then serves the content with the appropriate headers:

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

How modules register routes and permissions is covered under Routes & APIs and Permissions & roles.