Search by

un-dev-suisse / laravel-files-storage

Un dev Suisse

File stores for Laravel: named stores over filesystem disks, optional AES-256-GCM encryption with key rotation, an S3 round-trip check, and for spatie/laravel-backup an optional guard and an optional encryption of the archives with age.

Package info

gitlab.com/un_dev_suisse/packages/laravel-files-storage

Issues

pkg:composer/un-dev-suisse/laravel-files-storage

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

v1.1.0 2026-10-24 18:40 UTC

This package is auto-updated.

Last update: 2026-10-24 18:41:48 UTC


README

Named file stores over Laravel filesystem disks, with optional AES-256-GCM encryption and key rotation, a write/read/delete check of each disk, and for spatie/laravel-backup an optional guard and an optional encryption of the archives with age.

A store is a name declared in config/files-storage.php. Application code writes, reads and deletes files through the package's Actions and Queries, naming the store with a string or with a backed enum of its own.

Requirements: PHP 8.3+, Laravel 13.

Installation

composer require un-dev-suisse/laravel-files-storage
php artisan vendor:publish --tag=files-storage-config

The service provider is discovered by Laravel. The published file may keep only the keys it changes: each section is merged with the defaults key by key, and a list (stores, previous_keys) is taken from the application as a whole.

Declaring a store

The disks themselves stay in config/filesystems.php. A store says which disk it uses and what it does with its files:

// config/files-storage.php
'stores' => [
    'documents' => ['encrypted' => true],
    'archives' => ['disk' => 's3-archives', 'required_settings' => ['key', 'secret', 'region', 'bucket', 'endpoint']],
    'uploads' => ['mode' => 'none', 'scratch_disk' => 'local'],
],
SettingDefaultMeaning
diskthe store nameA name of filesystems.disks. Not used when mode is none.
modekeepkeep leaves the files on disk. none keeps nothing: a file waits on scratch_disk for its reading and the caller deletes it. Any value other than none is keep.
scratch_disklocalThe private disk where a file waits in mode none. A disk configured with visibility => public is refused (StoreNotConfigured).
encryptedfalseEncrypts the files before they are written and decrypts them as they are read.
required_settingskey, secret, region, bucketThe settings that must be filled when the driver of the disk is s3; the store is not available otherwise.

An undeclared store throws StoreNotConfigured.

Name the stores with a backed enum if you prefer; the package uses ->value:

enum Store: string
{
    case DOCUMENTS = 'documents';
}

app(WriteFileAction::class)->execute(Store::DOCUMENTS, 'user-1/contract.pdf', $uploadedFile->getRealPath());

Public API

use UnDevSuisse\FilesStorage\Actions\DeleteFileAction;
use UnDevSuisse\FilesStorage\Actions\DeleteFilesAfterCommitAction;
use UnDevSuisse\FilesStorage\Actions\EncryptStoredFileAction;
use UnDevSuisse\FilesStorage\Actions\WriteFileAction;
use UnDevSuisse\FilesStorage\Queries\DiskReadinessQuery;
use UnDevSuisse\FilesStorage\Queries\FileStoreCheckQuery;
use UnDevSuisse\FilesStorage\Queries\FileStoreQuery;

// $store is a string or a BackedEnum
app(WriteFileAction::class)->execute($store, string $path, string $localPath): void;
app(DeleteFileAction::class)->execute($store, string $path): void;
app(DeleteFilesAfterCommitAction::class)->execute($store, array $paths): void;
app(EncryptStoredFileAction::class)->execute($store, string $path, bool $dryRun = false): StoredFileState;

app(FileStoreQuery::class)->mode($store): StoreMode;
app(FileStoreQuery::class)->isConfigured($store): bool;
app(FileStoreQuery::class)->exists($store, string $path): bool;
app(FileStoreQuery::class)->contents($store, string $path): ?string;
app(FileStoreQuery::class)->attachment($store, string $path, string $name, array $headers): StreamedResponse;

app(FileStoreCheckQuery::class)->check($store): StoreCheck;
app(DiskReadinessQuery::class)->forDisk(string $disk): DiskReadinessData;
  • contents() returns null for a file the disk does not have. A file the disk has and cannot read is a failure, never null.
  • DeleteFileAction reports a failure and goes no further: the row that pointed at the file is already gone.
  • DeleteFilesAfterCommitAction deletes once the surrounding database transaction is committed, and at once outside one.
  • In mode none the package only picks the scratch disk; deleting the file once it is read is the caller's (DeleteFileAction).

Encryption

Set encrypted on a store, and give the package a key:

php artisan key:generate --show   # prints a base64: key of 32 bytes
FILES_STORAGE_ENCRYPTION_KEY=base64:...
  • Files are encrypted with AES-256-GCM in memory before they are written: the disk and the provider only see encrypted bytes. The path of the file is part of the authenticated data, so a file moved to another path does not open.
  • A store that encrypts has no disk without a usable key, in every mode: nothing is written in clear. A key equal to APP_KEY is never usable, and in production neither is a key listed in encryption.refused_keys (put the public key of an example .env there).
  • Reads hand the files out decrypted. A file written before the store encrypted is handed out as it is.
  • encryption.header (default LFSENC) and encryption.key_id_label (default laravel-files-storage-key-id) are part of the file format. An application that already holds files written with other values sets them to those values and its files stay readable. A header shorter than 4 bytes is refused: the store has no usable key.

Rotating the key

  1. Put the old key in previous_keys (FILES_STORAGE_ENCRYPTION_PREVIOUS_KEYS, comma separated) and set the new one as key. Old files stay readable; new files use the new key.
  2. Write the old files again under the new key:

    php artisan files-storage:encrypt documents --dry-run   # counts only
    php artisan files-storage:encrypt documents
    
  3. Once no file is under the old key, remove it from previous_keys.

Losing the key loses the files.

Commands

files-storage:check

Writes a small file to the disk of every declared store, reads it back and deletes it. One row per store: OK, not configured, not used (mode none) or FAILED with the error of the provider. The exit code is 1 when a configured store fails; a store without its settings is not a failure.

files-storage:encrypt {store} {--dry-run}

Encrypts the files of a store that are in clear or under a key of previous_keys, in place, and prints counts. It walks every file of the disk of the store (allFiles()), so the root of that disk must hold nothing but the files of the store. It loads the complete list of files in memory, skips the files whose path has a segment starting with a dot (.gitignore), and refuses a store that does not encrypt, a store in mode none, a store that is not available, and a store whose disk is the default disk of the application (filesystems.default) or the disk or scratch_disk of another store. A file already under the current key is left alone, so running it again changes nothing. A copy that cannot be written or does not read back is replaced by the content the file had; the exit code is 1 when a file failed.

Backup guard

spatie/laravel-backup encrypts its archive only when a password is set, and says nothing otherwise. The guard checks the backup configuration (the destination disks' settings, an https endpoint in production, the archive password and its minimum length in production) and reports config.key: reason strings, never values.

FILES_STORAGE_BACKUP_GUARD=true
FILES_STORAGE_BACKUP_MIN_PASSWORD_LENGTH=32

With files-storage.backups.guard on and spatie/laravel-backup installed, the package refuses to create an archive whose configuration is incomplete or unsafe. With it off nothing is registered and nothing is refused.

Scheduling stays in the application; the guard skips a task and logs why instead of failing the scheduler every night:

Schedule::command('backup:run --only-db')
    ->daily()
    ->when(fn () => app(BackupGuard::class)->allowsScheduledRun('backup:run'));

BackupGuard::problems() returns the list on its own.

Encrypting the backups with age

With public age keys in files-storage.backups.age_recipients, every archive created by spatie/laravel-backup is encrypted with age before it is copied to its destinations. Only the private key reads it back, and it is never on the server.

FILES_STORAGE_BACKUP_AGE_RECIPIENTS=age1...,age1...
  • The age binary must be on the PATH of the application. Without it, or with a recipient age refuses, the backup fails and nothing is copied: the archive never leaves the server in clear. The message names the reason, never a recipient.
  • The archive needs no password then: the guard checks age and the recipients instead of backup.backup.password.
  • The stored file keeps its .zip name, so that backup:monitor and backup:clean still see it; its content is the age file. backup.backup.verify_backup must stay off: spatie/laravel-backup checks the archive once it is encrypted, an age file is not a zip, and every backup would fail. The guard reports it.
  • The recipients are read at each backup, by the encryption and by the guard alike. Empty (the default), the archives are stored as spatie/laravel-backup creates them.

Creating the keys

Create a key pair on a machine other than the server, keep the private key offline in two places, and give the server the public key only:

age-keygen -pq -o backup-key.txt   # post-quantum hybrid key (ML-KEM-768 + X25519), age v1.3.0 and later
age-keygen -y backup-key.txt       # prints the public key, age1pq1...

Post-quantum keys are built into age since v1.3.0 (December 2025) and are a regular feature of it, not an experiment; their public keys are about 2,000 characters long. age-keygen without -pq creates a classic X25519 key (age1...), readable by older versions. age refuses to mix post-quantum and classic recipients in one file, so the list holds one kind only. Several keys of one kind (two administrators, a spare key) are allowed: any one of them decrypts.

Installing age in the image

The Debian and Alpine packages may be older than v1.3.0, which post-quantum keys need. The release binary is static (TARGETARCH, amd64 or arm64, is set by BuildKit):

ARG AGE_VERSION=v1.3.2
ARG TARGETARCH
RUN curl -fsSL "https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-${TARGETARCH}.tar.gz" \
    | tar -xz -C /usr/local/bin --strip-components=1 age/age

Restoring

age -d -i backup-key.txt 2026-10-24-02-00-00.zip > backup.zip
unzip backup.zip

Guarantees

  • The package itself never logs or prints a path, a file name, a content or a key. A failure it reports is a StoreFailed naming the operation and the class of the error of the disk, never its message (the messages of Flysystem carry the path).
  • exists(), contents() and attachment() throw the FilesystemException of the disk again after reporting that StoreFailed. Its message can carry the path: the caller catches it, and does not log or display its message.
  • An encrypted store never writes in clear: without a usable key it has no disk.
  • With age recipients, a backup archive that cannot be encrypted stops the backup before any copy.
  • A file that failed to encrypt keeps the content it had.
  • The check deletes its test file even when the write or the read failed, and reports a deletion that failed.
  • Console output carries counts and statuses only.

Development

composer check   # pint, larastan (level max), phpunit