pretend = (bool) $this->option('dry-run'); if ($this->pretend) { $this->components->warn(__('console.prune.dry_run')); } $this->pruneRevisions(); $this->pruneTrash(); $this->pruneMissingUrls(); $this->pruneViews(); // Files are the one thing here that cannot be undone by restoring a // row, so they are opt-in rather than part of the routine. if ($this->option('media')) { $this->pruneMedia(); } return self::SUCCESS; } /** * Old history, except the record's first and last entries. * * Keeping those two costs almost nothing and preserves the two facts * anybody ever asks a history for years later: when it appeared, and who * touched it last. */ protected function pruneRevisions(): void { $days = max(1, (int) $this->option('history-days')); $cutoff = now()->subDays($days); $keep = Revision::query() ->selectRaw('MIN(id) as id') ->groupBy('revisionable_type', 'revisionable_id') ->pluck('id') ->merge(Revision::query() ->selectRaw('MAX(id) as id') ->groupBy('revisionable_type', 'revisionable_id') ->pluck('id')); $query = Revision::query() ->where('created_at', '<', $cutoff) ->whereNotIn('id', $keep); $count = $query->count(); if (! $this->pretend && $count > 0) { $query->delete(); } $this->report(__('console.prune.revisions'), $count); } /** * Records that have been in the bin long enough that nobody is coming back * for them. */ protected function pruneTrash(): void { $days = max(1, (int) $this->option('trash-days')); $cutoff = now()->subDays($days); $total = 0; foreach (array_keys(Trash::RECOVERABLE) as $model) { /** @var class-string $model */ $records = $model::onlyTrashed()->where('deleted_at', '<', $cutoff)->get(); $total += $records->count(); if ($this->pretend) { continue; } // One at a time rather than a mass delete: the models have events // — the history records the erasure, the content version moves — // and a bulk query would skip every one of them. $records->each(fn (Model $record) => $record->forceDelete()); } $this->report(__('console.prune.trash'), $total); } /** * Addresses that were asked for once and never again. * * The table is written by strangers, so it is the one thing here that can * grow without anybody in the collective doing anything: a scanner walking * random paths leaves a row per path it invents. What survives is what a * person could act on — anything hit more than once, and anything already * resolved or deliberately ignored, because forgetting those would bring * the same decision back next week as though it had never been made. */ protected function pruneMissingUrls(): void { $days = max(1, (int) $this->option('missing-days')); $query = MissingUrl::query() ->pending() ->where('hits', '<', 2) ->where('last_seen_at', '<', now()->subDays($days)); $count = $query->count(); if (! $this->pretend && $count > 0) { $query->delete(); } $this->report(__('console.prune.missing_urls'), $count); } /** * Reading figures old enough that nobody compares against them. * * A row per record, per language, per day adds up quietly: it is the only * table here that grows every day the site is up, whatever anybody does. * The window is over a year on purpose — "more than last spring" is a * question people ask, and answering it costs a few thousand rows. */ protected function pruneViews(): void { $days = max(1, (int) $this->option('views-days')); $query = ContentView::query() ->where('day', '<', now()->subDays($days)->toDateString()); $count = $query->count(); if (! $this->pretend && $count > 0) { $query->delete(); } $this->report(__('console.prune.views'), $count); } /** * Image files no record mentions any more. * * Changing a project's cover leaves the old file on disk for ever, along * with every derived version of it. Nothing breaks; the disk just fills up * with images nobody can reach. */ protected function pruneMedia(): void { $referenced = Media::referencedPaths(); $freed = 0; $count = 0; foreach (Media::query()->get() as $media) { if (in_array($media->path, $referenced, true)) { continue; } $count++; $freed += (int) $media->size; if ($this->pretend) { continue; } $media->deleteDerived(); Storage::disk($media->disk)->delete($media->path); $media->delete(); } $this->report( __('console.prune.media'), $count, $count > 0 ? round($freed / 1024 / 1024, 1).' MB' : null, ); } protected function report(string $label, int $count, ?string $extra = null): void { $value = $count === 0 ? '—' : "{$count}"; $this->components->twoColumnDetail($label, $extra ? "{$value} · {$extra}" : $value); } }