jackardios / laravel-file-stash
Fetch and cache files from local filesystem, cloud storage or public webservers in Laravel
Requires
- php: ^8.1
- ext-curl: *
- guzzlehttp/guzzle: ^7.0
- illuminate/console: ^10.0 || ^11.0 || ^12.0
- illuminate/filesystem: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- psr/log: ^2.0 || ^3.0
- symfony/finder: ^6.0 || ^7.0
Requires (Dev)
- laravel/laravel: ^10.0 || ^11.0 || ^12.0
- mockery/mockery: ^1.2
- php-mock/php-mock-phpunit: ^2.13
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^9.0 || ^10.0 || ^11.0
This package is auto-updated.
Last update: 2026-03-16 14:16:08 UTC
README
Fetch and cache files from HTTP, cloud storage, or any Laravel disk — safely, even under heavy concurrency.
The Problem
You have queue workers processing jobs that need the same files — images, documents, exports. Each worker fetches its own copy. Downloads duplicate. Disk fills up. Workers corrupt each other's writes. Pruning deletes a file another worker is reading.
Worker A ──fetch──► image.jpg ──write──► /cache/abc123 ✗ corrupt
Worker B ──fetch──► image.jpg ──write──► /cache/abc123 ✗ overwrite
Worker C ──────────read───────────────► /cache/abc123 ✗ partial data
Pruner ──────────delete─────────────► /cache/abc123 ✗ gone mid-read
The Solution
File Stash gives every worker a safe, shared file cache with proper locking:
Worker A ──fetch──► image.jpg ──LOCK_EX──► /cache/abc123 ──unlock──► done
Worker B ────────── (waits) ──────────────LOCK_SH──► read ──► done
Worker C ────────── (waits) ──────────────LOCK_SH──► read ──► done
Pruner ────────── (skips locked files) ────────────────────► prune
One download. Shared reads. No corruption. No race conditions.
When Is This Useful?
File Stash helps when your application needs to download a file and process it locally — not just move it between storages, but actually do something with the bytes:
- Image processing pipelines — resize, watermark, or convert images from S3/CDN across multiple queue workers
- PDF processing — extract text, merge, or convert documents from cloud storage
- ML/AI workers — load model weights or input data from cloud storage, process locally
- Video/audio processing — extract thumbnails, transcode, analyze media files
- Data import jobs — parse CSV/Excel files from external sources in parallel workers
- Email attachments — fetch, attach, and discard temporary files
In all these cases, Storage::get() returns file contents as a string (loaded into memory). File Stash gives you a local file path you can pass to any tool — FFmpeg, Imagick, Python scripts, shell commands — without holding the entire file in PHP memory.
How It Works
┌───────────────────────────────┐
│ File Stash Cache │
│ /storage/cache/files/ │
│ │
┌─────────────┐ get() │ ┌─────────┐ File exists? │
│ Worker 1 │────────►│ │ SHA-256 │──── yes ──► LOCK_SH ──► read ──► callback
└─────────────┘ │ │ hash │ │
┌─────────────┐ get() │ │ │──── no ───► fetch ──► LOCK_EX ──► write
│ Worker 2 │────────►│ └─────────┘ │ │
└─────────────┘ │ │ │
┌─────────────┐ get() │ Sources: │ │
│ Worker 3 │────────►│ • https://... ◄────┘ │
└─────────────┘ │ • s3://... │
│ • local://... │
┌─────────────┐ prune() │ │
│ Scheduler │────────►│ File-level locks prevent │
└─────────────┘ │ deletion of files in use │
└───────────────────────────────┘
Each file is identified by a SHA-256 hash of its URL. The first worker to request a file fetches and caches it; subsequent workers read the cached copy through a shared lock. File-level locks prevent pruning of files that are currently in use, and lifecycle locks coordinate destructive operations like clear().
Installation
composer require jackardios/laravel-file-stash
The service provider and FileStash facade are auto-discovered.
Publish the config (optional):
php artisan vendor:publish --provider="Jackardios\FileStash\FileStashServiceProvider" --tag="config"
Requirements: PHP ^8.1, Laravel ^10 / ^11 / ^12
Quick Start
Process a remote file locally
use FileStash; use Jackardios\FileStash\GenericFile; $file = new GenericFile('https://cdn.example.com/uploads/photo.jpg'); FileStash::get($file, function ($file, $cachedPath) { // $cachedPath is a real local path — pass it to any tool $image = Image::read($cachedPath); $image->resize(300, 200)->save(storage_path('thumbs/photo.jpg')); });
Process a file from a Laravel storage disk
Any configured disk works — S3, GCS, SFTP, local. The prefix before :// is the disk name from config/filesystems.php:
// "reports" is the disk name from config/filesystems.php $file = new GenericFile('reports://exports/sales-2024.xlsx'); FileStash::get($file, function ($file, $path) { // $path is a local file — pass it to any library that needs a file path $spreadsheet = IOFactory::load($path); $data = $spreadsheet->getActiveSheet()->toArray(); // Process $data... });
Note:
reports://refers to a Laravel disk namedreports, not a URL protocol. Configure your disks inconfig/filesystems.php.
Batch processing
Process multiple files — each is held with a shared lock so nothing gets pruned or corrupted during the callback:
$attachments = Attachment::where('report_id', $reportId)->get(); $files = $attachments->map( fn ($a) => new GenericFile($a->storage_url) )->all(); FileStash::batch($files, function ($files, $paths) { // All files are locked for reading — safe from pruning and deletion $zip = new ZipArchive(); $zip->open(storage_path('app/export.zip'), ZipArchive::CREATE); foreach ($paths as $i => $path) { $zip->addFile($path, basename($files[$i]->getUrl())); } $zip->close(); });
One-time files (auto-cleanup)
Use getOnce() when you only need the file temporarily — it's deleted from cache after the callback:
$invoice = new GenericFile('https://billing.example.com/invoices/INV-2024-001.pdf'); FileStash::getOnce($invoice, function ($file, $path) { // Send email with attachment, then discard the cached file Mail::to('user@example.com')->send(new InvoiceMail($path)); }); // Cached file is automatically removed from disk
Custom File Implementations
GenericFile works for simple cases. For domain models, implement the File interface directly on your Eloquent model:
use Jackardios\FileStash\Contracts\File; class Document extends Model implements File { public function getUrl(): string { // Remote URL return $this->cdn_url; // Or a Laravel disk path // return "documents://{$this->path}"; } }
Then pass models directly:
$document = Document::find(1); FileStash::get($document, function ($document, $path) { // $document is your Eloquent model — access any attribute $text = (new PdfParser())->parseFile($path)->getText(); $document->update(['extracted_text' => $text]); });
File Sources
The URL prefix determines where the file is fetched from:
| URL format | Source | Example |
|---|---|---|
https://... or http://... |
Remote HTTP via Guzzle | https://cdn.example.com/photo.jpg |
diskname://path |
Laravel filesystem disk | photos://uploads/photo.jpg |
The diskname must match a key in your config/filesystems.php disks array.
Local file paths like
/var/files/image.jpgare not supported directly. Configure a local disk and usemydisk://image.jpg.
Concurrency Model
File Stash is designed for environments with multiple parallel queue workers processing the same files. Here's what happens under load:
Concurrent reads — safe
Worker A ─── get("img.jpg") ──► LOCK_SH ──► read ──► unlock
Worker B ─── get("img.jpg") ──► LOCK_SH ──► read ──► unlock (parallel, no waiting)
Worker C ─── get("img.jpg") ──► LOCK_SH ──► read ──► unlock
Multiple workers can read the same cached file simultaneously. Shared locks (LOCK_SH) do not block each other.
Write during reads — safe
Worker A ─── get("new.jpg") ──► cache miss ──► LOCK_EX ──► download + write ──► unlock
Worker B ─── get("new.jpg") ──► cache miss ──► waits... ──────────────────────► LOCK_SH ──► read
If two workers request the same uncached file, one acquires the exclusive lock and fetches; the other waits, then reads the cached result.
Batch + prune — safe
Worker ─── batch([a, b, c]) ──► LOCK_SH on each file ──► callback ──► unlock
Pruner ─── prune() ──► tries LOCK_EX on file ──► skipped (file is busy)
While your callback runs, each cached file is held with a shared lock (LOCK_SH). The pruner tries to acquire an exclusive lock (LOCK_EX) before deleting — if it can't, it skips the file. Your files are safe for the entire duration of the callback.
clear() goes further — it acquires an exclusive lifecycle lock, so it waits until all batch()/get() operations finish before deleting anything.
Lock configuration
// config/file-stash.php 'lock_max_attempts' => 3, // retries before giving up 'lock_wait_timeout' => -1, // seconds to wait (-1 = forever) 'lifecycle_lock_timeout' => 30, // seconds for batch/clear coordination
For strict latency requirements, throw instead of waiting:
use Jackardios\FileStash\Exceptions\FileLockedException; try { FileStash::get($file, $callback, throwOnLock: true); } catch (FileLockedException) { // File is busy, handle gracefully }
API Reference
Core Methods
// Cache and use a file FileStash::get(File $file, ?callable $callback, bool $throwOnLock = false): mixed // Cache, use, then delete FileStash::getOnce(File $file, ?callable $callback, bool $throwOnLock = false): mixed // Cache and use multiple files (prune-safe) FileStash::batch(array $files, ?callable $callback, bool $throwOnLock = false): mixed // Cache, use, then delete multiple files FileStash::batchOnce(array $files, ?callable $callback, bool $throwOnLock = false): mixed
Cache Management
FileStash::exists(File $file): bool // Check if a file's source exists FileStash::forget(File $file): bool // Remove a specific cached file FileStash::prune(): array // Remove expired/oversized files FileStash::clear(): void // Delete all unused cached files FileStash::metrics(): CacheMetrics // Get hit/miss/eviction counters
Pruning
Pruning removes cached files that are older than max_age or exceed the max_size limit.
Automatic pruning
The service provider registers a scheduled command that runs automatically. By default, it prunes every 5 minutes:
// config/file-stash.php 'prune_interval' => '*/5 * * * *',
Make sure Laravel's scheduler is running:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Manual pruning
Run the artisan command:
php artisan prune-file-stash
Or call it programmatically:
$stats = FileStash::prune(); // ['deleted' => 12, 'remaining' => 48, 'total_size' => 524288000, 'completed' => true]
Clearing all cache
FileStash::clear(); // Delete all unused cached files FileStash::forget($file); // Remove a specific file
The cache is also cleared automatically when you run php artisan cache:clear.
Events
Enable event dispatching for observability:
// config/file-stash.php 'events_enabled' => true,
| Event | When | Key properties |
|---|---|---|
CacheHit |
File served from cache | $file, $cachedPath |
CacheMiss |
File not cached, fetching | $file, $url |
CacheFileRetrieved |
File successfully cached | $file, $cachedPath, $bytes, $source |
CacheFileEvicted |
File deleted from cache | $path, $reason |
CachePruneCompleted |
Prune finished | $deleted, $remaining, $totalSize, $completed |
All events are in the Jackardios\FileStash\Events namespace.
When events_enabled is false (default), zero overhead — no objects allocated, no dispatching.
use Jackardios\FileStash\Events\CacheHit; Event::listen(CacheHit::class, function (CacheHit $event) { Log::info("Cache hit: {$event->file->getUrl()}"); });
Metrics
Track cache effectiveness per-process:
$metrics = FileStash::metrics(); $metrics->hits; // cache hits $metrics->misses; // cache misses $metrics->retrievals; // files fetched from source $metrics->evictions; // files removed $metrics->errors; // failed retrievals $metrics->hitRate(); // float|null — percentage $metrics->toArray(); // all counters as array $metrics->reset(); // zero out counters
Push to your monitoring stack:
app()->terminating(function () { $m = FileStash::metrics()->toArray(); // Send to Prometheus, StatsD, Datadog, etc. });
Configuration
All settings support environment variables. Publish the config to customize:
php artisan vendor:publish --provider="Jackardios\FileStash\FileStashServiceProvider" --tag="config"
Cache limits
| Key | Env | Default | Description |
|---|---|---|---|
path |
— | storage/framework/cache/files |
Cache directory |
max_file_size |
FILE_STASH_MAX_FILE_SIZE |
-1 (unlimited) |
Max file size in bytes |
max_age |
FILE_STASH_MAX_AGE |
60 |
TTL in minutes before pruning |
max_size |
FILE_STASH_MAX_SIZE |
1E+9 (1 GB) |
Soft limit for total cache size |
HTTP
| Key | Env | Default | Description |
|---|---|---|---|
timeout |
FILE_STASH_TIMEOUT |
-1 |
Total request timeout (seconds) |
connect_timeout |
FILE_STASH_CONNECT_TIMEOUT |
30 |
Connection timeout (seconds) |
read_timeout |
FILE_STASH_READ_TIMEOUT |
30 |
Stream read timeout (seconds) |
http_retries |
FILE_STASH_HTTP_RETRIES |
0 |
Retry attempts (4xx except 429 not retried) |
http_retry_delay |
FILE_STASH_HTTP_RETRY_DELAY |
100 |
Base delay in ms (exponential backoff) |
user_agent |
FILE_STASH_USER_AGENT |
Laravel-FileStash/4.x |
User-Agent header |
max_redirects |
FILE_STASH_MAX_REDIRECTS |
5 |
Max redirects to follow |
Security
| Key | Env | Default | Description |
|---|---|---|---|
allowed_hosts |
FILE_STASH_ALLOWED_HOSTS |
null (all) |
Host whitelist for SSRF protection |
mime_types |
— | [] (all) |
Allowed MIME types |
// Wildcards supported 'allowed_hosts' => ['example.com', '*.cdn.example.com'],
# Comma-separated in .env FILE_STASH_ALLOWED_HOSTS=example.com,*.cdn.example.com
Concurrency
| Key | Env | Default | Description |
|---|---|---|---|
lock_max_attempts |
FILE_STASH_LOCK_MAX_ATTEMPTS |
3 |
Lock acquisition retries |
lock_wait_timeout |
FILE_STASH_LOCK_WAIT_TIMEOUT |
-1 (forever) |
Lock wait timeout (seconds) |
lifecycle_lock_timeout |
FILE_STASH_LIFECYCLE_LOCK_TIMEOUT |
30 |
Batch/clear coordination timeout |
batch_chunk_size |
FILE_STASH_BATCH_CHUNK_SIZE |
100 |
Files per chunk (prevents fd exhaustion) |
Pruning
| Key | Env | Default | Description |
|---|---|---|---|
prune_interval |
FILE_STASH_PRUNE_INTERVAL |
*/5 * * * * |
Cron schedule for auto-pruning |
prune_timeout |
FILE_STASH_PRUNE_TIMEOUT |
300 |
Prune timeout (seconds) |
Performance
| Key | Env | Default | Description |
|---|---|---|---|
touch_interval |
FILE_STASH_TOUCH_INTERVAL |
60 |
Min seconds between touch() on hot files |
events_enabled |
FILE_STASH_EVENTS_ENABLED |
false |
Enable event dispatching |
Exceptions
All exceptions are in Jackardios\FileStash\Exceptions with public readonly properties for structured handling:
| Exception | When | Properties |
|---|---|---|
FileIsTooLargeException |
File exceeds max_file_size |
int $maxBytes |
FileLockedException |
File locked, throwOnLock is true |
— |
HostNotAllowedException |
Host not in allowed_hosts |
string $host |
MimeTypeIsNotAllowedException |
MIME type not allowed | string $mimeType |
InvalidConfigurationException |
Invalid config value | string $key, string $reason |
SourceResourceIsInvalidException |
Invalid stream resource | — |
SourceResourceTimedOutException |
Stream read timed out | — |
FailedToRetrieveFileException |
All retries exhausted | — |
use Jackardios\FileStash\Exceptions\HostNotAllowedException; try { FileStash::get($file, $callback); } catch (HostNotAllowedException $e) { Log::warning("Blocked: {$e->host}"); }
Testing
The facade ships with a fake that skips real HTTP/disk operations:
use FileStash; use Jackardios\FileStash\GenericFile; public function test_it_processes_file(): void { FileStash::fake(); $file = new GenericFile('https://example.com/image.jpg'); $result = FileStash::get($file, fn ($file, $path) => 'processed'); $this->assertEquals('processed', $result); }
The fake supports all contract methods: get, getOnce, batch, batchOnce, forget, exists, prune, clear, metrics.
Acknowledgements
This package is based on biigle/laravel-file-cache by Martin Zurowietz. It extends the original with lifecycle locks, batch chunking, events, metrics, SSRF protection, HTTP retries, and other improvements.
License
MIT