Implementing Digital Goods Sales (Files, Documents, Templates) on Your Website
Digital goods do not require warehouse or shipping management, but they do require a reliable access system: the file must be delivered to the buyer immediately after payment and protected from redistribution. Standard e-commerce engines either do not support digital goods or implement them primitively — via direct links to files in public/, which provides no protection.
Types of Digital Goods
- Documents — PDF, DOCX, legal templates, contracts, instructions
- Spreadsheets — XLSX templates, financial models, planners
- Design resources — PSD, Figma templates, icons, fonts
- Software — distributions, plugins, CMS themes
- Media — audio, video, high-resolution photos
- Educational content — courses as ZIP archives, e-books
System Architecture
Customer pays → PaymentConfirmedEvent
→ CreateDigitalOrderJob
→ generation of unique download link
→ send email with link
→ record in digital_order_downloads (limits)
Customer clicks link → DigitalDownloadController
→ token verification (valid? expired? limit not exceeded?)
→ file streaming via StreamedResponse (not via public URL)
→ record in download_events
Data Models
Schema::create('digital_products', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('storage_path'); // path in private storage (not 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 = unlimited
$table->integer('validity_days')->nullable(); // NULL = perpetual
$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(); // random secure token
$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');
});
Generating Download Link After Payment
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 characters
'downloads_count' => 0,
'downloads_limit' => $digitalProduct->download_limit,
'expires_at' => $digitalProduct->validity_days
? now()->addDays($digitalProduct->validity_days)
: null,
]);
// Send email with link
Mail::to($item->order->email)
->send(new DigitalDownloadReadyMail($download));
return $download;
}
}
Download Controller
The file is never served directly from the public directory. Only through a controller with checks:
class DigitalDownloadController
{
public function download(string $token): StreamedResponse
{
$download = DigitalOrderDownload::where('token', $token)->firstOrFail();
// Check expiration
if ($download->expires_at && $download->expires_at->isPast()) {
abort(410, 'Link expired');
}
// Check download limit
if ($download->downloads_limit !== null
&& $download->downloads_count >= $download->downloads_limit) {
abort(403, 'Download limit exceeded');
}
$dp = $download->digitalProduct;
// Check file existence
if (!Storage::disk('private')->exists($dp->storage_path)) {
abort(404, 'File not found');
}
// Record download
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(),
]);
});
// Stream file — do not create temporary copy in memory
return Storage::disk('private')->download(
$dp->storage_path,
$dp->original_filename,
['Content-Type' => $dp->mime_type]
);
}
}
Private Storage
Files are stored in a directory outside public/. In Laravel, use the private disk:
// config/filesystems.php
'private' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'permissions' => [
'file' => ['public' => 0640, 'private' => 0640],
'dir' => ['public' => 0750, 'private' => 0750],
],
],
For large files or high load — S3 with presigned URLs:
// Generate temporary signed S3 URL (15 minutes)
$url = Storage::disk('s3-private')->temporaryUrl(
$dp->storage_path,
now()->addMinutes(15),
['ResponseContentDisposition' => 'attachment; filename="' . $dp->original_filename . '"'],
);
return redirect($url);
Customer Personal Account
The "My Purchases" section displays:
- List of purchased digital goods
- Download button (inactive if limit exceeded or link expired)
- Number of remaining downloads
- Link expiration date
Protection Against Redistribution
- Unique token for each purchase — one token does not work for another order
- Download limit by count — typically 3–5 downloads
- Time limit — 30–90 days
- IP logging on every download — for leak investigation
Timeline
Complete system for digital goods sales (file uploads in admin, binding to products, sending after payment, download page, personal account) — 5–8 business days.







