Впровадження продажу цифрових товарів (файли, документи, шаблони) на сайті
Цифрові товари не потребують складу та доставки, але вимагають надійної системи доступу: файл повинен бути передан покупцю одразу після оплати та захищений від розповсюдження. Стандартні e-commerce движки або не підтримують цифрові товари, або реалізують їх примітивно — прямим посиланням на файл у public/, що не забезпечує захист.
Типи цифрових товарів
- Документи — PDF, DOCX, юридичні шаблони, контракти, інструкції
- Таблиці — XLSX-шаблони, фінансові моделі, планувальники
- Дизайн-ресурси — PSD, Figma-шаблони, іконки, шрифти
- Програмне забезпечення — дистрибутиви, плагіни, теми для CMS
- Медіа — аудіо, відео, фотографії високої роздільної здатності
- Освітній контент — курси у вигляді ZIP-архівів, електронні книги
Архітектура системи
Покупець оплачує → PaymentConfirmedEvent
→ CreateDigitalOrderJob
→ генерація унікального посилання для завантаження
→ відправка email з посиланням
→ запис у digital_order_downloads (обмеження)
Покупець клацає на посилання → DigitalDownloadController
→ перевірка токена (дійсний? не закінчився? ліміт не перевищений?)
→ потокова передача файлу через StreamedResponse (не через публічний URL)
→ запис у download_events
Моделі даних
Schema::create('digital_products', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('storage_path'); // шлях у приватному сховищі (не public)
$table->string('original_filename');
$table->string('mime_type');
$table->unsignedBigInteger('file_size_bytes');
$table->string('version')->nullable(); // v1.2.0
$table->integer('download_limit')->nullable(); // NULL = без обмежень
$table->integer('validity_days')->nullable(); // NULL = безстроково
$table->timestamps();
});
Schema::create('digital_order_downloads', function (Blueprint $table) {
$table->id();
$table->foreignId('order_item_id')->constrained();
$table->foreignId('digital_product_id')->constrained();
$table->string('token', 64)->unique(); // випадковий безпечний токен
$table->integer('downloads_count')->default(0);
$table->integer('downloads_limit')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index('token');
});
Schema::create('download_events', function (Blueprint $table) {
$table->id();
$table->foreignId('digital_order_download_id')->constrained();
$table->string('ip_address', 45);
$table->string('user_agent', 500)->nullable();
$table->timestamp('downloaded_at');
$table->index('downloaded_at');
});
Генерація посилання після оплати
class CreateDigitalDownloadAction
{
public function execute(OrderItem $item): DigitalOrderDownload
{
$digitalProduct = $item->product->digitalProduct;
if (!$digitalProduct) {
throw new NotADigitalProductException($item->product_id);
}
$download = DigitalOrderDownload::create([
'order_item_id' => $item->id,
'digital_product_id' => $digitalProduct->id,
'token' => bin2hex(random_bytes(32)), // 64 символи
'downloads_count' => 0,
'downloads_limit' => $digitalProduct->download_limit,
'expires_at' => $digitalProduct->validity_days
? now()->addDays($digitalProduct->validity_days)
: null,
]);
// Відправляємо email з посиланням
Mail::to($item->order->email)
->send(new DigitalDownloadReadyMail($download));
return $download;
}
}
Контролер завантаження
Файл ніколи не відається безпосередньо з публічної директорії. Тільки через контролер з перевірками:
class DigitalDownloadController
{
public function download(string $token): StreamedResponse
{
$download = DigitalOrderDownload::where('token', $token)->firstOrFail();
// Перевіряємо термін дії
if ($download->expires_at && $download->expires_at->isPast()) {
abort(410, 'Посилання закінчилося');
}
// Перевіряємо ліміт завантажень
if ($download->downloads_limit !== null
&& $download->downloads_count >= $download->downloads_limit) {
abort(403, 'Ліміт завантажень вичерпаний');
}
$dp = $download->digitalProduct;
// Перевіряємо існування файлу
if (!Storage::disk('private')->exists($dp->storage_path)) {
abort(404, 'Файл не знайдено');
}
// Фіксуємо завантаження
DB::transaction(function () use ($download) {
$download->increment('downloads_count');
DownloadEvent::create([
'digital_order_download_id' => $download->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'downloaded_at' => now(),
]);
});
// Передаємо потоком — не створюємо тимчасову копію в пам'яті
return Storage::disk('private')->download(
$dp->storage_path,
$dp->original_filename,
['Content-Type' => $dp->mime_type]
);
}
}
Приватне сховище
Файли зберігаються в директорії поза public/. У Laravel використовується диск private:
// config/filesystems.php
'private' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'permissions' => [
'file' => ['public' => 0640, 'private' => 0640],
'dir' => ['public' => 0750, 'private' => 0750],
],
],
Для великих файлів або високого навантаження — S3 з попередньо підписаними посиланнями:
// Генерація тимчасового підписаного посилання S3 (15 хвилин)
$url = Storage::disk('s3-private')->temporaryUrl(
$dp->storage_path,
now()->addMinutes(15),
['ResponseContentDisposition' => 'attachment; filename="' . $dp->original_filename . '"'],
);
return redirect($url);
Особистий кабінет покупця
Розділ «Мої покупки» відображає:
- Список куплених цифрових товарів
- Кнопку завантаження (неактивна при вичерпанні ліміту або закінченні терміну)
- Кількість залишених завантажень
- Термін дії посилання
Захист від розповсюдження
- Унікальний токен для кожної покупки — один токен не підходить для іншого замовлення
- Ліміт завантажень за кількістю — стандартно 3–5 завантажень
- Ліміт за часом — 30–90 днів
- Логування IP при кожному завантаженні — для розслідування витоків
Терміни
Повна система продажу цифрових товарів (завантаження файлів в адмінці, прив'язка до товарів, відправка після оплати, сторінка завантаження, особистий кабінет) — 5–8 робочих днів.







