laravel-files-storage maintained by un-dev-suisse
Laravel files storage
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 an optional guard for spatie/laravel-backup.
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 documents -
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.
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.
- 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