Разработка продажи цифровых товаров (файлы, документы, шаблоны) на сайте
Цифровые товары не требуют склада и доставки, но требуют надёжной системы доступа: файл должен быть передан покупателю сразу после оплаты и защищён от распространения. Стандартные e-commerce движки либо не поддерживают цифровые товары, либо реализуют их примитивно — прямой ссылкой на файл в public/, что не является защитой.
Типы цифровых товаров
- Документы — PDF, DOCX, юридические шаблоны, договоры, инструкции
- Таблицы — XLSX-шаблоны, финансовые модели, планировщики
- Дизайн-ресурсы — PSD, Figma-шаблоны, иконки, шрифты
- Программное обеспечение — дистрибутивы, плагины, темы для CMS
- Медиа — аудио, видео, фотографии в высоком разрешении
- Образовательный контент — курсы в виде ZIP-архивов, электронные книги
Архитектура системы
Покупатель оплачивает → PaymentConfirmedEvent
→ CreateDigitalOrderJob
→ генерация уникальной ссылки на скачивание
→ отправка email с ссылкой
→ запись в digital_order_downloads (лимиты)
Покупатель кликает по ссылке → DigitalDownloadController
→ проверка токена (действителен? не истёк? лимит не превышен?)
→ стриминг файла через StreamedResponse (не через public 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 с presigned URLs:
// Генерация временной подписанной ссылки 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 рабочих дней.







