Implementing Download Limits (by Count/Time) for Digital Products
Download restrictions are not just protection against illegal distribution, but also a commercial tool: different pricing tiers can provide different numbers of downloads or access duration. Implementation must handle edge cases: simultaneous clicks, reconnections, closed browser mid-download.
Types of Restrictions
| Type | Description | Example |
|---|---|---|
| By count | N downloads per purchase | 3 downloads |
| By time | Access until a specific date | 30 days after payment |
| Combined | Both count and time | 5 downloads or 90 days |
| By IP | Only from registered IP | Corporate licenses |
| By device | Binding to fingerprint | For software |
Restriction Model
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(); // NULL = unlimited
$table->timestamp('expires_at')->nullable(); // NULL = perpetual
$table->boolean('is_revoked')->default(false); // manual revocation
$table->timestamps();
$table->index(['token', 'is_revoked']);
});
Restriction Check Service
class DownloadLimitGuard
{
/**
* Check if download is available and return denial reason (if any)
*/
public function check(DigitalOrderDownload $download): DownloadCheckResult
{
if ($download->is_revoked) {
return DownloadCheckResult::denied('revoked', 'Access has been revoked');
}
if ($download->expires_at && $download->expires_at->isPast()) {
return DownloadCheckResult::denied(
'expired',
'Link validity period expired ' . $download->expires_at->format('d.m.Y'),
);
}
if ($download->downloads_limit !== null
&& $download->downloads_count >= $download->downloads_limit) {
return DownloadCheckResult::denied(
'limit_reached',
"Download limit exhausted ({$download->downloads_count} of {$download->downloads_limit})",
);
}
return DownloadCheckResult::allowed(
remainingDownloads: $download->downloads_limit !== null
? $download->downloads_limit - $download->downloads_count
: null,
expiresAt: $download->expires_at,
);
}
}
Atomic Counter Increment
Problem: two simultaneous requests can both pass the limit check before the counter updates. Solution — pessimistic row locking:
class RecordDownloadAction
{
public function execute(DigitalOrderDownload $download, Request $request): void
{
DB::transaction(function () use ($download, $request) {
// Lock the row for update — SELECT ... FOR UPDATE
$locked = DigitalOrderDownload::lockForUpdate()->findOrFail($download->id);
// Recheck limit inside transaction
if ($locked->downloads_limit !== null
&& $locked->downloads_count >= $locked->downloads_limit) {
throw new DownloadLimitExceededException();
}
$locked->increment('downloads_count');
DownloadEvent::create([
'digital_order_download_id' => $locked->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'referer' => $request->header('Referer'),
'downloaded_at' => now(),
]);
});
}
}
Handling Interrupted Downloads
HTTP Range requests enable resumable downloads. The browser sends Range: bytes=1048576- requesting continuation. If each Range request counts as a separate download, the limit exhausts on a single large file download.
Solution: count as download only the first request without Range header or with Range: bytes=0-:
public function download(string $token, Request $request): Response
{
$download = DigitalOrderDownload::where('token', $token)->firstOrFail();
$guard = app(DownloadLimitGuard::class);
$result = $guard->check($download);
if (!$result->isAllowed()) {
return response()->view('digital.download-denied', ['reason' => $result->reason], 403);
}
$rangeHeader = $request->header('Range');
$isFirstRequest = !$rangeHeader || $rangeHeader === 'bytes=0-';
if ($isFirstRequest) {
app(RecordDownloadAction::class)->execute($download, $request);
}
return $this->streamFile($download->digitalProduct);
}
Limit Configuration by Pricing Tier
Different products can have different default limits, and the tier on purchase overrides them:
// digital_products.download_limit — default limit for product
// Overridden when creating DigitalOrderDownload based on tier
class CreateDigitalDownloadAction
{
public function execute(OrderItem $item): DigitalOrderDownload
{
$dp = $item->product->digitalProduct;
$plan = $item->plan_id ? Plan::find($item->plan_id) : null;
$limit = $plan?->download_limit ?? $dp->download_limit;
$validityDays = $plan?->validity_days ?? $dp->validity_days;
return DigitalOrderDownload::create([
'order_item_id' => $item->id,
'digital_product_id' => $dp->id,
'token' => bin2hex(random_bytes(32)),
'downloads_limit' => $limit,
'expires_at' => $validityDays ? now()->addDays($validityDays) : null,
]);
}
}
Administrative Control
Available actions in control panel:
- Reset counter — restore limit on customer technical issue
-
Extend deadline — change
expires_atmanually -
Revoke access — set
is_revoked = true -
Add downloads — increase
downloads_limit
// Example: extend deadline via artisan command or API
$download->update(['expires_at' => $download->expires_at->addDays(30)]);
Expiry Notifications
3 days before expiry, send email reminder:
$schedule->command('digital:notify-expiring --days=3')->dailyAt('10:00');
Timelines
Basic restrictions (counter + deadline) with atomic increment — 2–3 working days. Range request handling, IP/device restrictions, pricing tiers — another 2–3 days.







