Signed temporary download URLs with a download cap
Hand out expiring signed links for private files and stop serving them after a fixed number of downloads, without exposing storage paths.
LaravelUploadssigned-urldownloadstorageexpiry
Code
<?php
namespace App\Http\Controllers;
use App\Models\Transfer;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadController extends Controller
{
/** A link that stops working after 24 hours, whatever the download count. */
public function link(Transfer $transfer): string
{
return URL::temporarySignedRoute(
'transfers.download',
min($transfer->expires_at, now()->addDay()),
['transfer' => $transfer->id],
);
}
public function show(Transfer $transfer): StreamedResponse
{
// One conditional UPDATE: concurrent requests cannot both take the last slot.
$claimed = Transfer::whereKey($transfer->id)
->whereColumn('downloads', '<', 'max_downloads')
->increment('downloads');
abort_if($claimed === 0, 410, 'This link has reached its download limit.');
return Storage::disk('private')->download($transfer->disk_path, $transfer->name);
}
}
<?php
// routes/web.php (excerpt)
use App\Http\Controllers\DownloadController;
use Illuminate\Support\Facades\Route;
// `signed` rejects tampered or expired URLs with a 403 before the controller runs.
Route::get('/d/{transfer}', [DownloadController::class, 'show'])
->middleware(['signed', 'throttle:30,1'])
->name('transfers.download');
How it works
What it solves
Sharing a private file by link normally means making it public or building a token table. Laravel's signed URLs give you tamper-proof, expiring links with no extra storage, and a small counter adds the other rule file transfers need: this link works N times, then stops.
How it works
link() calls URL::temporarySignedRoute with the file's id and an expiry. The signature covers the whole URL, so changing the id or the expiry breaks it and the signed middleware answers 403. Nothing about disk paths ever reaches the client.
show() loads the transfer, then atomically claims a download slot with where downloads < max_downloads plus increment. Doing it as one conditional update means two simultaneous requests cannot both take the last slot. If no row was changed the controller returns 410 Gone, which tells clients the link is finished rather than wrong.
The file is streamed with Storage::download, which sets the disposition header and supports private disks such as S3 without loading the file into memory.
Adapting it
Change the expiry per transfer, add a password check before the claim, or call URL::signedRoute with no expiry for permanent links you can still revoke by deleting the row.
Pitfalls
- Claim the slot before streaming; failed downloads still use a slot, which is the safe default.
- Behind a proxy, set trusted proxies or the signature host will not match.
- Signed URLs prove authenticity, not identity; do not use them for per-user permissions.
How to use it
- Add a
transferstable withdisk_path,name,max_downloads,downloads(default 0) andexpires_at. - Copy the controller and add the routes excerpt.
- Generate a link with
app(DownloadController::class)->link($transfer)or callURL::temporarySignedRoutedirectly. - Keep files on a private disk such as
localors3. - Show 410 pages for expired or used-up links in your exception handler if you want a friendlier message.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.