Реализация системы скачивания файлов после оплаты
Система скачивания после оплаты — критический путь в продаже цифровых товаров. Файл должен стать доступен в течение секунд после подтверждения платежа, ссылка должна быть одноразовой или ограниченной, а сам файл — никогда не отдаваться напрямую из публичной директории.
Поток данных
Платёж подтверждён (webhook от эквайера)
↓
PaymentController::webhook()
↓
PaymentConfirmedEvent → CreateDownloadLinksListener
↓
foreach (order.items as item where item.is_digital):
CreateDigitalDownloadAction::execute(item)
→ DigitalOrderDownload (token, limits, expiry)
↓
DigitalDownloadReadyMail → покупатель получает email
↓
Покупатель кликает ссылку → /download/{token}
↓
DigitalDownloadController::download(token)
→ валидация токена
→ стриминг файла
Обработка webhook платёжной системы
class PaymentWebhookController
{
public function handle(Request $request, string $provider): JsonResponse
{
$handler = PaymentHandlerFactory::make($provider);
// Верифицируем подпись вебхука
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]);
}
}
Немедленное vs. асинхронное создание ссылок
Синхронно (inline в Listener) — покупатель получает email через 1–2 секунды после оплаты. Подходит для малого числа позиций.
Асинхронно (через Queue) — надёжнее при высокой нагрузке. Email может задержаться на несколько секунд, но не задержит HTTP-ответ вебхуку.
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);
}
}
}
Стриминг крупных файлов
При отдаче файлов PHP важно не загружать весь файл в память. Laravel Storage::download() использует стриминг автоматически, но для очень крупных файлов (>500 МБ) лучше использовать X-Accel-Redirect (nginx) или presigned URL (S3):
// Вариант 1: X-Accel-Redirect (nginx обслуживает файл напрямую, PHP только авторизует)
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 конфиг
location /private-files/ {
internal;
alias /var/www/storage/app/private/;
}
// Вариант 2: S3 Presigned URL (для больших файлов, CDN-выдача)
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 с ссылкой на скачивание
class DigitalDownloadReadyMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
private readonly DigitalOrderDownload $download,
) {}
public function build(): self
{
$downloadUrl = route('digital.download', $this->download->token);
return $this
->subject('Ваша покупка готова к скачиванию')
->markdown('emails.digital-download-ready', [
'downloadUrl' => $downloadUrl,
'productName' => $this->download->digitalProduct->product->name,
'expiresAt' => $this->download->expires_at?->format('d.m.Y'),
'downloadsLimit' => $this->download->downloads_limit,
]);
}
}
Страница скачивания
Опционально — промежуточная веб-страница вместо прямого редиректа на файл. Позволяет показать инструкцию по открытию файла, кнопку повторного скачивания, рекомендации сопутствующих товаров.
// Роут: GET /download/{token} — страница
// Роут: GET /download/{token}/file — реальное скачивание
Route::get('/download/{token}', [DigitalDownloadController::class, 'show'])->name('digital.show');
Route::get('/download/{token}/file', [DigitalDownloadController::class, 'download'])->name('digital.download');
Повторная отправка ссылки
Покупатель может запросить повторную отправку письма через личный кабинет или форму на странице заказа (с проверкой email):
public function resend(DigitalOrderDownload $download): JsonResponse
{
if ($download->expires_at?->isPast()) {
return response()->json(['error' => 'Ссылка истекла'], 410);
}
// Rate limiting: не чаще 1 раза в 5 минут
RateLimiter::attempt('resend-download:' . $download->token, 1, function () use ($download) {
Mail::to($download->orderItem->order->email)
->send(new DigitalDownloadReadyMail($download));
}, 300);
return response()->json(['ok' => true]);
}
Сроки
Базовая система (webhook → создание ссылки → email → стриминг через PHP) — 3–4 рабочих дня. X-Accel-Redirect или S3 presigned URL + страница скачивания + повторная отправка — 5–6 рабочих дней.







