Skip to main content
WintertraceLive demo
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.

What changed in 1.2.0

Stored files are private by default. Four consequences, and the last one is the one that catches people out:

  • The local backend writes to the private disk (storage/app/private/), not to storage/app/public/. Nothing you store is reachable by guessing a URL any more.
  • url() no longer returns a permanent public URL. It returns a signed, permission-checked URL, valid for 24 hours, pointing at the core’s media.show route.
  • Every module that stores files must register its path prefix with MediaAccessRegistry. This is not optional and not a hardening nicety — an unregistered prefix is served to nobody and migrated for nobody.
  • Files written by earlier versions stay readable, because the local backend falls back to the public disk on read. But they stay publicly readable until the prefix is registered.

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.

url() deserves a closer look since 1.2.0. It returns a link that a browser session belonging to an authorised viewer can open. It is not a public URL and not a permalink: the local backend signs it for 24 hours, and the core still runs the MediaAccessRegistry check on every single request.

So do not email it, do not store it in the database, and above all do not hand it to a third-party service that fetches it server-side — a chat API, a webhook consumer, an external PDF renderer. That fetch carries no session and gets a 403. Send the bytes instead, through retrieveWithFallback().

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 — signed, 24 h, and checked against
// MediaAccessRegistry on every request. Only useful for a
// logged-in viewer's own browser.
$url = $storage->urlWithFallback('photos/job-42.jpg');

The documents/ prefix above is only served once your module has registered it. That is the next section.

Media access: register your prefix (required)

MediaAccessRegistry maps a path prefix to an authoriser callback, and it is default-deny: a prefix nobody has claimed is refused, whoever is asking.

The same prefix list drives three separate mechanisms, which is why skipping the registration is a silent bug rather than a missing feature:

Prefix registeredPrefix not registered
media.show serves the file to viewers your authoriser approvesmedia.show returns 403 to everyone, administrators included
The /storage/… fallback route refuses the prefix outrightThe fallback route keeps serving your legacy files to anyone holding the path
PublicMediaMigrator moves your pre-1.2.0 files to the private diskYour pre-1.2.0 files stay on the public disk indefinitely

Register in your service provider’s boot(), next to your other registry calls:

use App\Services\Storage\MediaAccessRegistry;
use Illuminate\Contracts\Auth\Authenticatable;

app(MediaAccessRegistry::class)->register(
    'dokumente/',
    function (string $path, ?Authenticatable $viewer): bool {
        if ($viewer === null) {
            return false;
        }

        $document = Document::where('storage_path', $path)->first();

        // A path under our prefix with no row behind it is not ours to serve.
        if ($document === null) {
            return false;
        }

        // One branch per guard — never a ?-> chain. User ids and customer ids
        // are separate spaces that collide: customer #7 must never be compared
        // against a User-side id 7.
        if ($viewer instanceof \App\Models\User) {
            return $viewer->isAdmin();
        }

        if ($viewer instanceof \App\Models\Customer) {
            return $viewer->id === $document->customer_id;
        }

        // A guard we do not know about is not a reason to hand out bytes.
        return false;
    }
);

Rules for the authoriser:

  • Match the prefix to what you actually write. dokumente/ covers dokumente/{customer}/{id}/{uuid}.pdf. Matching is a plain str_starts_with, so keep the trailing slash — doku would also claim dokumentation/.
  • Return false on anything unexpected. A throwing authoriser is caught, logged and treated as a refusal, but do not rely on that as control flow.
  • Do not query the session, auth() or the request. The $viewer argument is the whole input. It may be a User, a Customer, or null for a guest.
  • Register at boot, not lazily. The migrator and the fallback route read prefixes() without ever calling allows(), so a registration that only happens on a download route leaves both of them unprotected.

Registering only affects file access. Serving your own authenticated download route is still the right pattern for documents — the registry is what protects the bytes when somebody reaches for them directly.

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.

There is a second fallback one level down, and it is easy to miss. The local backend reads from the private disk first and then from the public disk, so files written before 1.2.0 keep working. retrieveWithFallback() cannot do this itself — it falls back to local, and on a default installation local is already the active backend. If you write your own backend, do not try to reimplement this: call retrieveWithFallback() and let the local backend handle its own legacy.

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

    // Must NOT return a permanently public object URL. Either return a
    // provider-side pre-signed URL with a short lifetime, or point at the
    // core's media.show route the way the local backend does — otherwise
    // the MediaAccessRegistry check is bypassed for every file in the bucket.
    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 database — file_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.