prunable models, and the scheduled delete you forgot to schedule

The Prunable trait declares which rows are expendable and does nothing until something runs the command.

final class ReceivedWebhook extends Model
{
    use Prunable;

    public function prunable(): Builder
    {
        return static::where('created_at', '<', now()->subDays(14));
    }

    protected function pruning(): void
    {
        Storage::delete($this->payload_path);   // per row
    }
}

// and the half that is always missing:
// $schedule->command('model:prune')->daily();

The pruning hook loads and deletes row by row, which is correct when there is an associated file and is a very slow way to delete four million rows. MassPrunable is the alternative and skips the hook entirely, so the choice is between per-row cleanup and a bulk delete — and choosing wrongly means either a job that runs for hours or orphaned files nobody notices.