un-dev-suisse / laravel-files-storage
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
pkg:composer/un-dev-suisse/laravel-files-storage
Requires
- php: ^8.3
- ext-openssl: *
- illuminate/console: ^13.0
- illuminate/database: ^13.0
- illuminate/filesystem: ^13.0
- illuminate/http: ^13.0
- illuminate/process: ^13.0
- illuminate/support: ^13.0
- league/flysystem-aws-s3-v3: ^3.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.0
- spatie/laravel-backup: ^10.0
Suggests
- spatie/laravel-backup: Required by the backup guard (files-storage.backups.guard) and the age encryption of the archives (files-storage.backups.age_recipients).
Provides
None
Conflicts
None
Replaces
None
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'],
],
| Setting | Default | Meaning |
|---|---|---|
disk | the store name | A name of filesystems.disks. Not used when mode is none. |
mode | keep | keep 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_disk | local | The private disk where a file waits in mode none. A disk configured with visibility => public is refused (StoreNotConfigured). |
encrypted | false | Encrypts the files before they are written and decrypts them as they are read. |
required_settings | key, secret, region, bucket | The 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()returnsnullfor a file the disk does not have. A file the disk has and cannot read is a failure, nevernull.DeleteFileActionreports a failure and goes no further: the row that pointed at the file is already gone.DeleteFilesAfterCommitActiondeletes once the surrounding database transaction is committed, and at once outside one.- In mode
nonethe 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_KEYis never usable, and in production neither is a key listed inencryption.refused_keys(put the public key of an example.envthere). - Reads hand the files out decrypted. A file written before the store encrypted is handed out as it is.
encryption.header(defaultLFSENC) andencryption.key_id_label(defaultlaravel-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
- Put the old key in
previous_keys(FILES_STORAGE_ENCRYPTION_PREVIOUS_KEYS, comma separated) and set the new one askey. Old files stay readable; new files use the new key. Write the old files again under the new key:
php artisan files-storage:encrypt documents --dry-run # counts only php artisan files-storage:encrypt documentsOnce 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
agebinary must be on the PATH of the application. Without it, or with a recipientagerefuses, 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
ageand the recipients instead ofbackup.backup.password. - The stored file keeps its
.zipname, so thatbackup:monitorandbackup:cleanstill see it; its content is theagefile.backup.backup.verify_backupmust stay off:spatie/laravel-backupchecks the archive once it is encrypted, anagefile 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-backupcreates 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
StoreFailednaming the operation and the class of the error of the disk, never its message (the messages of Flysystem carry the path). exists(),contents()andattachment()throw theFilesystemExceptionof the disk again after reporting thatStoreFailed. 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