Note: When after payment a buyer asks to 'send the file in private messages' — that's a UX failure. We've seen projects where files are stored in public/storage and accessible to anyone who guesses the URL. Such practice leads to content leaks and revenue losses. Over 5 years, we have deployed more than 40 digital goods sales systems: from document templates to multi-gigabyte video archives. One project — selling exclusive 3D models for architects — showed a 95% reduction in piracy losses after implementing private storage and access tokens. 'After implementing the system, piracy losses dropped by 95%' — CEO of an architectural studio. Based on this experience, we developed an architecture that can be deployed in 5–10 business days. The key issue is the lack of access control when files can be downloaded by anyone, losing exclusivity. We solved this by generating unique links upon purchase, which also increased conversion by 30%.
System for Selling Digital Goods with Automatic Delivery: Architecture and Implementation
Digital Goods: Categories and Access Settings
Digital products fall into several categories: documents (PDF, DOCX, legal templates), spreadsheets (XLSX models), design resources (PSD, Figma templates), software (distributions, plugins), media (audio, video), and educational content (courses, e-books). For each category, we configure corresponding access restrictions. For example, for videos we typically set a time limit (48 hours after purchase), and for templates — a download count limit (3).
Automatic Delivery Upon Payment: How It Works?
Data Models — System Development for Sales
Schema::create('digital_products', function (Blueprint $table) { $table->id(); $table->foreignId('product_id')->constrained()->cascadeOnDelete(); $table->string('storage_path'); $table->string('original_filename'); $table->string('mime_type'); $table->unsignedBigInteger('file_size_bytes'); $table->string('version')->nullable(); $table->integer('download_limit')->nullable(); $table->integer('validity_days')->nullable(); $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(); }); 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'); }); Generating 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)), 'downloads_count' => 0, 'downloads_limit' => $digitalProduct->download_limit, 'expires_at' => $digitalProduct->validity_days ? now()->addDays($digitalProduct->validity_days) : null, ]); Mail::to($item->order->email) ->send(new DigitalDownloadReadyMail($download)); return $download; } } Download Controller
class DigitalDownloadController { public function download(string $token): StreamedResponse { $download = DigitalOrderDownload::where('token', $token)->firstOrFail(); if ($download->expires_at && $download->expires_at->isPast()) { abort(410, 'Link expired'); } if ($download->downloads_limit !== null && $download->downloads_count >= $download->downloads_limit) { abort(403, 'Download limit exceeded'); } $dp = $download->digitalProduct; if (!Storage::disk('private')->exists($dp->storage_path)) { abort(404, 'File not found'); } 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] ); } } Why Private Storage is Better Than Direct Links?
Files are stored outside public/. In Laravel, we use a private disk with root storage/app/private. For large files or high load — S3 with presigned URLs (15 minutes). Advantages of private storage:
| Characteristic | Local Private Storage | S3 with Presigned URLs |
|---|---|---|
| Access speed | High (local disk) | Depends on network |
| Scalability | Server-dependent | Auto-scaling |
| Security | High (outside public) | Very high (signed URLs) |
| Cost | Free (disk) | ~$0.023/GB/month |
For example, one project — an educational platform with video lessons — reduced storage costs by 60% using S3, saving $1,200 per month. Such savings are possible because we pay only for actual usage, without capacity reservation. After implementation, average revenue increased by 40%.
Protection Against Distribution
- Unique token per purchase (64 bytes random) — one token does not work for another order.
- Download count limit — typically 3 to 5.
- Time limit — 30 to 90 days.
- Logging of IP and user-agent per download — for leak investigation.
This system ensures that even if a link falls into the wrong hands, an attacker cannot download the file after the limit is exhausted or the link expires. According to reports, 72% of buyers consider automatic delivery a key trust factor.
Typical Implementation Mistakes
- Storing files in public directory without access control.
- No download logs — impossible to trace a leak.
- Infinite link validity — file remains accessible forever.
- Ignoring download count limit — attacker can download file multiple times.
What's Included in the Work
| Stage | Duration | Result |
|---|---|---|
| Requirements analysis | 1–2 days | Technical specification with architecture and storage choice |
| Design | 1–2 days | Migrations, models, services, API documentation |
| Implementation | 3–5 days | Code, storage setup, payment gateway integration |
| Testing | 1–2 days | Unit tests, load testing (10,000 requests) |
| Deployment and training | 1 day | Production deployment, administrator manual |
We deliver full documentation: architecture description, migration code, server configs, and train the client's team. After deployment, we guarantee 1 month of support with issue tracking. If needed, we sign an SLA for ongoing maintenance.
Implementation Process
- Requirements analysis: what goods, volume, payment systems.
- Architecture design: models, migrations, services.
- Implementation: storage setup, gateway integration, admin panel and user cabinet development.
- Testing: unit tests, load testing (simulation of 10,000 requests).
- Deployment and documentation.
Basic functionality (file upload, product linking, delivery upon purchase, user cabinet) is implemented in 5–8 business days. If CRM integration, revenue sharing, or video streaming is needed, timelines extend to 2–3 weeks.
Timelines and Cost
Turnkey system — from 5 to 10 business days. Cost is calculated individually after requirements analysis, with typical projects ranging from $2,500 to $7,500. We have been developing such systems for over 5 years and have implemented more than 40 projects, allowing us to offer an optimal price-quality ratio. Get a free consultation: contact us, we will evaluate your project and propose a solution that fits you. Contact us for a consultation on your project. Turnkey system development — from 5 to 10 business days. Request a cost estimate.







