Implementing File Download System After Payment
File download after payment is a critical path in digital goods sales. The file must become available within seconds of payment confirmation, the link must be single-use or limited, and the file itself should never be served directly from a public directory.
Data Flow
Payment confirmed (webhook from payment processor)
↓
PaymentController::webhook()
↓
PaymentConfirmedEvent → CreateDownloadLinksListener
↓
foreach (order.items as item where item.is_digital):
CreateDigitalDownloadAction::execute(item)
→ DigitalOrderDownload (token, limits, expiry)
↓
DigitalDownloadReadyMail → customer receives email
↓
Customer clicks link → /download/{token}
↓
DigitalDownloadController::download(token)
→ token validation
→ file streaming
Handling Payment Webhook
class PaymentWebhookController
{
public function handle(Request $request, string $provider): JsonResponse
{
$handler = PaymentHandlerFactory::make($provider);
// Verify webhook signature
if (!$handler->verifySignature($request)) {
Log::warning('Invalid payment webhook signature', ['provider' => $provider]);
abort(400);
}
$paymentResult = $handler->parse($request);
if ($paymentResult->isSuccessful()) {
$order = Order::where('payment_id', $paymentResult->transactionId)->firstOrFail();
DB::transaction(function () use ($order, $paymentResult) {
$order->update([
'status' => 'paid',
'paid_at' => now(),
'payment_id' => $paymentResult->transactionId,
]);
event(new PaymentConfirmedEvent($order));
});
}
return response()->json(['ok' => true]);
}
}
Synchronous vs. Asynchronous Link Creation
Synchronously (inline in Listener) — customer receives email 1–2 seconds after payment. Works for small number of items.
Asynchronously (via Queue) — more reliable under high load. Email may delay a few seconds, but won't delay webhook HTTP response.
class CreateDownloadLinksListener implements ShouldQueue
{
public $queue = 'digital-downloads';
public $tries = 5;
public $backoff = [5, 15, 30, 60, 120];
public function handle(PaymentConfirmedEvent $event): void
{
$order = $event->order;
$digitalItems = $order->items->filter(
fn($item) => $item->product->digitalProduct !== null
);
foreach ($digitalItems as $item) {
app(CreateDigitalDownloadAction::class)->execute($item);
}
}
}
Streaming Large Files
When serving files from PHP, don't load entire file into memory. Laravel's Storage::download() uses streaming automatically, but for very large files (>500 MB) use X-Accel-Redirect (nginx) or presigned URL (S3):
// Option 1: X-Accel-Redirect (nginx serves file directly, PHP only authorizes)
public function downloadViaAccel(DigitalOrderDownload $download): Response
{
$this->validateDownload($download);
$this->recordDownload($download);
$internalPath = '/private-files/' . $download->digitalProduct->storage_path;
return response('', 200, [
'X-Accel-Redirect' => $internalPath,
'Content-Type' => $download->digitalProduct->mime_type,
'Content-Disposition' => 'attachment; filename="' . $download->digitalProduct->original_filename . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
# nginx config
location /private-files/ {
internal;
alias /var/www/storage/app/private/;
}
// Option 2: S3 Presigned URL (for large files, CDN delivery)
public function downloadViaS3(DigitalOrderDownload $download): RedirectResponse
{
$this->validateDownload($download);
$this->recordDownload($download);
$url = Storage::disk('s3')->temporaryUrl(
path: $download->digitalProduct->storage_path,
expiration: now()->addMinutes(10),
options: [
'ResponseContentDisposition' => sprintf(
'attachment; filename="%s"',
$download->digitalProduct->original_filename
),
]
);
return redirect($url);
}
Email with Download Link
class DigitalDownloadReadyMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
private DigitalOrderDownload $download,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your Digital Product is Ready to Download',
);
}
public function content(): Content
{
return new Content(
view: 'mails.digital-download-ready',
with: [
'downloadUrl' => route('downloads.show', $this->download->token),
'expiresAt' => $this->download->expires_at,
'remainingDownloads' => $this->download->remaining_downloads,
],
);
}
}
Timeline
Basic implementation: webhook handling + single-use links + email — 2–3 days. Complete system: multiple file types, streaming, presigned URLs, download limits, expiry management — 4–6 days.







