Add document access statistics to admin dashboard

Records every successful document download and surfaces the counts in the
admin area, so the docket owner can see which filings are being pulled and
how often.

- document_accesses table + DocumentAccess model
- DocumentController@download records each successful download with IP and
  user agent
- documents:import-access-log backfills history from an archived nginx log
- Dashboard gains a 60-day downloads card and a most-requested list
- New Document Access page with a date-range filter and per-day trend

Client IPs were only captured from 2026-07-30, when the reverse proxy chain
started forwarding X-Forwarded-For correctly. Rows imported from the old
nginx log therefore have no IP, and the UI labels those periods as download
counts rather than unique visitors instead of showing a misleading number.
This commit is contained in:
friday-bot 2026-07-30 13:14:01 -06:00
parent 13e7d23e0a
commit 04bd71aaaa
9 changed files with 650 additions and 3 deletions

View file

@ -0,0 +1,118 @@
<?php
namespace App\Console\Commands;
use App\Models\Document;
use App\Models\DocumentAccess;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ImportDocumentAccessLog extends Command
{
protected $signature = 'documents:import-access-log
{file : Path to an nginx access log in NCSA combined format}
{--source=nginx-backfill : Value stored in the source column}
{--dry-run : Parse and report without writing anything}';
protected $description = 'Backfill document_accesses from an archived nginx access log';
/**
* Matches e.g.
* 172.18.0.1 - - [23/May/2026:18:50:19 +0000] "GET /api/documents/83/download HTTP/1.1" 200 12345 "-" "Mozilla/5.0 ..."
*
* Only status 200 counts as an access: 499 means the client aborted the
* transfer, and 4xx/5xx never delivered the file.
*/
private const LINE = '#^\S+ \S+ \S+ \[([^\]]+)\] "GET /api/documents/(\d+)/download [^"]*" 200 \S+ "[^"]*" "([^"]*)"#';
public function handle(): int
{
$file = $this->argument('file');
if (! is_readable($file)) {
$this->error("Cannot read file: {$file}");
return self::FAILURE;
}
$source = $this->option('source');
$dryRun = $this->option('dry-run');
// Only import accesses for documents that still exist; the FK would
// reject the rest and a deleted document has no dashboard row anyway.
$knownIds = Document::pluck('id')->flip();
$handle = fopen($file, 'r');
$rows = [];
$parsed = $skippedUnknown = $unmatched = 0;
while (($line = fgets($handle)) !== false) {
if (! preg_match(self::LINE, $line, $m)) {
// Most lines are ordinary page views, not downloads.
if (str_contains($line, '/download')) {
$unmatched++;
}
continue;
}
[, $timestamp, $documentId, $userAgent] = $m;
if (! $knownIds->has((int) $documentId)) {
$skippedUnknown++;
continue;
}
$rows[] = [
'document_id' => (int) $documentId,
'accessed_at' => Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp)->utc(),
'ip_address' => null, // never recorded by the old log format
'user_agent' => $userAgent,
'source' => $source,
'created_at' => now(),
'updated_at' => now(),
];
$parsed++;
}
fclose($handle);
$this->info("Parsed {$parsed} successful downloads from {$file}");
if ($skippedUnknown > 0) {
$this->warn("Skipped {$skippedUnknown} referring to documents that no longer exist");
}
if ($unmatched > 0) {
$this->warn("Skipped {$unmatched} download lines that were not status 200 (aborted or failed)");
}
if ($dryRun) {
$this->comment('Dry run: nothing written.');
return self::SUCCESS;
}
// Idempotency: a re-run must not double-count. Every row from a given
// log carries the same source, so clearing that source first makes the
// import safely repeatable.
$existing = DocumentAccess::where('source', $source)->count();
if ($existing > 0) {
if (! $this->confirm("{$existing} rows with source '{$source}' already exist. Replace them?", true)) {
$this->comment('Aborted; nothing written.');
return self::SUCCESS;
}
DocumentAccess::where('source', $source)->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
DocumentAccess::insert($chunk);
}
$this->info('Imported '.count($rows)." rows with source '{$source}'.");
return self::SUCCESS;
}
}

View file

@ -5,7 +5,10 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\DocketEntry; use App\Models\DocketEntry;
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentAccess;
use App\Models\Subscription; use App\Models\Subscription;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -34,7 +37,42 @@ class DashboardController extends Controller
]; ];
}), }),
]; ];
$stats += $this->documentAccessSummary();
return Inertia::render('Admin/Dashboard', $stats); return Inertia::render('Admin/Dashboard', $stats);
} }
/**
* Headline document-access numbers for the last 60 days, plus the most
* requested documents. Crawlers are excluded so the figures reflect people.
*/
private function documentAccessSummary(): array
{
$since = Carbon::now()->subDays(60)->startOfDay();
return [
'downloads_60d' => DocumentAccess::excludingBots()
->where('accessed_at', '>=', $since)
->count(),
'top_documents_60d' => DocumentAccess::query()
->excludingBots()
->join('documents', 'documents.id', '=', 'document_accesses.document_id')
->where('document_accesses.accessed_at', '>=', $since)
->groupBy('documents.id', 'documents.title')
->orderByDesc('downloads')
->take(5)
->get([
'documents.id',
'documents.title',
DB::raw('count(*) as downloads'),
])
->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'downloads' => (int) $row->downloads,
]),
];
}
} }

View file

@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\DocumentAccess;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class DocumentAccessController extends Controller
{
/**
* The date real client IPs started being recorded. Before this, the nginx
* log stored only the docker gateway address, so unique-visitor counts are
* not available for earlier periods and must not be implied.
*/
private const IP_DATA_STARTS = '2026-07-30';
/**
* Per-document access counts over a selectable date range.
*/
public function index(Request $request): Response
{
$validated = $request->validate([
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'include_bots' => ['nullable', 'boolean'],
]);
$from = isset($validated['from'])
? Carbon::parse($validated['from'])->startOfDay()
: Carbon::now()->subDays(60)->startOfDay();
$to = isset($validated['to'])
? Carbon::parse($validated['to'])->endOfDay()
: Carbon::now()->endOfDay();
$includeBots = (bool) ($validated['include_bots'] ?? false);
$documents = $this->documentCounts($from, $to, $includeBots);
return Inertia::render('Admin/DocumentAccess', [
'documents' => $documents,
'daily' => $this->dailyCounts($from, $to, $includeBots),
'filters' => [
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'include_bots' => $includeBots,
],
'totals' => [
'downloads' => $documents->sum('downloads'),
'documents_touched' => $documents->count(),
],
// Drives the caveat shown in the UI.
'ip_data_starts' => self::IP_DATA_STARTS,
'range_predates_ip_data' => $from->lt(Carbon::parse(self::IP_DATA_STARTS)),
]);
}
/**
* Download counts per document, plus unique visitors where the underlying
* rows actually carry an IP.
*/
private function documentCounts(Carbon $from, Carbon $to, bool $includeBots)
{
$query = DocumentAccess::query()
->join('documents', 'documents.id', '=', 'document_accesses.document_id')
->whereBetween('document_accesses.accessed_at', [$from, $to]);
if (! $includeBots) {
$query->excludingBots();
}
return $query
->groupBy('documents.id', 'documents.title')
->orderByDesc('downloads')
->get([
'documents.id',
'documents.title',
DB::raw('count(*) as downloads'),
DB::raw('count(distinct document_accesses.ip_address) as unique_visitors'),
DB::raw('max(document_accesses.accessed_at) as last_accessed'),
])
->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'downloads' => (int) $row->downloads,
'unique_visitors' => (int) $row->unique_visitors,
'last_accessed' => Carbon::parse($row->last_accessed)->format('m/d/Y'),
]);
}
/**
* Downloads per day, for the trend chart.
*/
private function dailyCounts(Carbon $from, Carbon $to, bool $includeBots)
{
$query = DocumentAccess::query()
->whereBetween('accessed_at', [$from, $to]);
if (! $includeBots) {
$query->excludingBots();
}
return $query
->groupBy('day')
->orderBy('day')
->get([
DB::raw('date(accessed_at) as day'),
DB::raw('count(*) as downloads'),
])
->map(fn ($row) => [
'day' => Carbon::parse($row->day)->format('m/d'),
'downloads' => (int) $row->downloads,
]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentAccess;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@ -12,7 +13,7 @@ class DocumentController extends Controller
/** /**
* Download a document by ID. * Download a document by ID.
*/ */
public function download(int $id): StreamedResponse public function download(Request $request, int $id): StreamedResponse
{ {
$document = Document::findOrFail($id); $document = Document::findOrFail($id);
@ -22,6 +23,17 @@ class DocumentController extends Controller
abort(404, 'Document file not found.'); abort(404, 'Document file not found.');
} }
// Record the access before streaming, so the dashboard reflects it.
// Only successful downloads reach this point, which matches how the
// historical nginx rows were filtered (status 200 only).
DocumentAccess::create([
'document_id' => $document->id,
'accessed_at' => now(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'source' => 'live',
]);
// Stream the file to the browser // Stream the file to the browser
return Storage::disk('public')->download( return Storage::disk('public')->download(
$document->file_path, $document->file_path,

View file

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DocumentAccess extends Model
{
protected $table = 'document_accesses';
protected $fillable = [
'document_id',
'accessed_at',
'ip_address',
'user_agent',
'source',
];
protected $casts = [
'accessed_at' => 'datetime',
];
/**
* Get the document that was accessed.
*/
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
/**
* Requests whose user agent looks like a crawler rather than a person.
* Kept as a query-time filter rather than a write-time one so the raw
* record stays complete and the definition can be changed later.
*/
public function scopeExcludingBots($query)
{
foreach (['%bot%', '%crawl%', '%spider%', '%slurp%'] as $pattern) {
$query->where(function ($q) use ($pattern) {
$q->whereNull('user_agent')
->orWhere('user_agent', 'not ilike', $pattern);
});
}
return $query;
}
}

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('document_accesses', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->cascadeOnDelete();
$table->timestamp('accessed_at');
// Null for rows backfilled from the old nginx log: that log recorded
// only the docker gateway address, so the real client IP for anything
// before 2026-07-30 was never captured and cannot be recovered.
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
// 'nginx-backfill' = imported from the archived access log (no IP).
// 'live' = recorded by the app at download time (has IP).
$table->string('source', 20)->default('live');
$table->timestamps();
$table->index(['document_id', 'accessed_at']);
$table->index('accessed_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_accesses');
}
};

View file

@ -10,11 +10,19 @@ interface DocketEntry {
documents_count: number; documents_count: number;
} }
interface TopDocument {
id: number;
title: string;
downloads: number;
}
interface Props { interface Props {
total_entries: number; total_entries: number;
total_documents: number; total_documents: number;
total_subscribers: number; total_subscribers: number;
recent_entries: DocketEntry[]; recent_entries: DocketEntry[];
downloads_60d: number;
top_documents_60d: TopDocument[];
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@ -61,7 +69,7 @@ const logout = () => {
<!-- Main Content --> <!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total Entries --> <!-- Total Entries -->
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@ -112,6 +120,58 @@ const logout = () => {
</div> </div>
</div> </div>
</div> </div>
<!-- Document Downloads (60 days) -->
<Link
:href="route('admin.document-access.index')"
class="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600">Downloads (60 days)</p>
<p class="text-3xl font-bold text-gray-900 mt-2">
{{ props.downloads_60d }}
</p>
<p class="text-xs text-gray-500 mt-1">Excludes crawlers</p>
</div>
<div class="p-3 bg-amber-100 rounded-full">
<svg class="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
</div>
</Link>
</div>
<!-- Most Requested Documents -->
<div class="bg-white rounded-lg shadow mb-8">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 class="text-xl font-bold text-gray-900">Most Requested Documents</h2>
<p class="text-sm text-gray-600 mt-1">Last 60 days</p>
</div>
<Link
:href="route('admin.document-access.index')"
class="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
View all &amp; change dates &rarr;
</Link>
</div>
<div class="divide-y divide-gray-200">
<div
v-for="doc in props.top_documents_60d"
:key="doc.id"
class="px-6 py-4 flex justify-between items-center hover:bg-gray-50 transition-colors"
>
<span class="text-sm font-medium text-gray-900">{{ doc.title }}</span>
<span class="px-3 py-1 text-xs font-medium text-amber-700 bg-amber-100 rounded-full whitespace-nowrap ml-4">
{{ doc.downloads }} download{{ doc.downloads !== 1 ? 's' : '' }}
</span>
</div>
<div v-if="props.top_documents_60d.length === 0" class="px-6 py-8 text-center text-gray-500">
No document downloads recorded in the last 60 days.
</div>
</div>
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->

View file

@ -0,0 +1,205 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
interface DocumentRow {
id: number;
title: string;
downloads: number;
unique_visitors: number;
last_accessed: string;
}
interface DailyRow {
day: string;
downloads: number;
}
interface Props {
documents: DocumentRow[];
daily: DailyRow[];
filters: { from: string; to: string; include_bots: boolean };
totals: { downloads: number; documents_touched: number };
ip_data_starts: string;
range_predates_ip_data: boolean;
}
const props = defineProps<Props>();
const from = ref(props.filters.from);
const to = ref(props.filters.to);
const includeBots = ref(props.filters.include_bots);
const apply = () => {
router.get(
route('admin.document-access.index'),
{ from: from.value, to: to.value, include_bots: includeBots.value },
{ preserveState: true, preserveScroll: true },
);
};
const preset = (days: number) => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - days);
from.value = start.toISOString().slice(0, 10);
to.value = end.toISOString().slice(0, 10);
apply();
};
// Scale the inline bar chart to the busiest day in range.
const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)));
</script>
<template>
<div class="min-h-screen bg-gray-100">
<Head title="Document Access" />
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">Document Access</h1>
<p class="text-sm text-gray-600 mt-1">
How often each document has been downloaded
</p>
</div>
<Link
:href="route('admin.dashboard')"
class="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 transition-colors"
>
&larr; Back to Dashboard
</Link>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Filters -->
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">From</label>
<input
v-model="from"
type="date"
class="border-gray-300 rounded-lg shadow-sm text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">To</label>
<input
v-model="to"
type="date"
class="border-gray-300 rounded-lg shadow-sm text-sm"
/>
</div>
<button
@click="apply"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Apply
</button>
<div class="flex gap-2">
<button
v-for="p in [30, 60, 90]"
:key="p"
@click="preset(p)"
class="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
>
Last {{ p }}d
</button>
</div>
<label class="flex items-center gap-2 ml-auto text-sm text-gray-700">
<input
v-model="includeBots"
type="checkbox"
@change="apply"
class="rounded border-gray-300"
/>
Include crawlers
</label>
</div>
</div>
<!-- Summary -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Total downloads</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.downloads }}</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Documents downloaded</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.documents_touched }}</p>
</div>
</div>
<!-- Caveat: unique visitors are not knowable before IP logging began -->
<div
v-if="props.range_predates_ip_data"
class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"
>
<p class="text-sm text-amber-900">
<span class="font-semibold">Downloads, not unique visitors.</span>
Visitor IP addresses were only recorded from
{{ props.ip_data_starts }} onward. For any earlier period the same
person downloading a document ten times counts as ten downloads, and
the unique-visitor column will read 0.
</p>
</div>
<!-- Daily trend -->
<div v-if="props.daily.length > 0" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-xl font-bold text-gray-900 mb-4">Downloads per day</h2>
<div class="flex items-end gap-1 h-40 overflow-x-auto">
<div
v-for="d in props.daily"
:key="d.day"
class="flex-1 min-w-[8px] bg-blue-500 hover:bg-blue-600 rounded-t transition-colors"
:style="{ height: (d.downloads / peak) * 100 + '%' }"
:title="`${d.day}: ${d.downloads} download${d.downloads !== 1 ? 's' : ''}`"
/>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-2">
<span>{{ props.daily[0]?.day }}</span>
<span>peak {{ peak }}/day</span>
<span>{{ props.daily[props.daily.length - 1]?.day }}</span>
</div>
</div>
<!-- Per-document table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">By document</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Document</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique visitors</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Last accessed</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="doc in props.documents" :key="doc.id" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-medium text-gray-900">{{ doc.title }}</td>
<td class="px-6 py-4 text-sm text-gray-900 text-right">{{ doc.downloads }}</td>
<td class="px-6 py-4 text-sm text-right" :class="doc.unique_visitors === 0 ? 'text-gray-400' : 'text-gray-900'">
{{ doc.unique_visitors === 0 ? '—' : doc.unique_visitors }}
</td>
<td class="px-6 py-4 text-sm text-gray-600 text-right">{{ doc.last_accessed }}</td>
</tr>
<tr v-if="props.documents.length === 0">
<td colspan="4" class="px-6 py-8 text-center text-gray-500">
No downloads recorded in this date range.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
</template>

View file

@ -38,6 +38,9 @@ Route::prefix('admin')->name('admin.')->group(function () {
Route::post('/docket-entries/{docketEntry}/documents', [\App\Http\Controllers\Admin\DocumentController::class, 'store'])->name('documents.store'); Route::post('/docket-entries/{docketEntry}/documents', [\App\Http\Controllers\Admin\DocumentController::class, 'store'])->name('documents.store');
Route::delete('/documents/{document}', [\App\Http\Controllers\Admin\DocumentController::class, 'destroy'])->name('documents.destroy'); Route::delete('/documents/{document}', [\App\Http\Controllers\Admin\DocumentController::class, 'destroy'])->name('documents.destroy');
// Document access statistics
Route::get('/document-access', [\App\Http\Controllers\Admin\DocumentAccessController::class, 'index'])->name('document-access.index');
// Subscriber routes // Subscriber routes
Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index'); Route::get('/subscribers', [\App\Http\Controllers\Admin\SubscriberController::class, 'index'])->name('subscribers.index');
Route::delete('/subscribers/{subscription}', [\App\Http\Controllers\Admin\SubscriberController::class, 'destroy'])->name('subscribers.destroy'); Route::delete('/subscribers/{subscription}', [\App\Http\Controllers\Admin\SubscriberController::class, 'destroy'])->name('subscribers.destroy');