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 tostorage/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’smedia.showroute.- 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 registered | Prefix not registered |
|---|---|
media.show serves the file to viewers your authoriser approves | media.show returns 403 to everyone, administrators included |
The /storage/… fallback route refuses the prefix outright | The fallback route keeps serving your legacy files to anyone holding the path |
PublicMediaMigrator moves your pre-1.2.0 files to the private disk | Your 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/coversdokumente/{customer}/{id}/{uuid}.pdf. Matching is a plainstr_starts_with, so keep the trailing slash —dokuwould also claimdokumentation/. - Return
falseon 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$viewerargument is the whole input. It may be aUser, aCustomer, ornullfor a guest. - Register at boot, not lazily. The migrator and the fallback route read
prefixes()without ever callingallows(), 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:
- Never store files in
public/— always use the storage backend. Anything inpublic/is reachable from the web without any access check. - Use relative paths — the backend handles absolute paths. This keeps code independent of whether storage is local or in the cloud.
- Prefix paths with your module slug — for example
documents/,invoices/. This keeps storage organised and prevents collisions between modules. - Serve files through authenticated routes — check permissions before any download.
- 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.