PHP Content Pruner (Auto-Delete Old Files)

December 31, 2025

This is a tiny PHP “janitor” you can drop into almost any site to automatically delete old files (logs, cache files, old JSON exports, temporary uploads) after a set number of days.

What it does:

  • Deletes files older than X days from one or more folders.
  • Supports safe allowed extensions (so you don’t accidentally delete important files).
  • Prevents deleting anything outside your chosen folders (path safety checks).
  • Works on shared hosting without cron: you can run it “sometimes” by including it on a page that gets traffic.

Install (2 options):

  1. Manual run (recommended): upload prune.php, then visit it in your browser with a token: /prune.php?token=YOUR_TOKEN
  2. Auto run (no cron): include it in your footer/header and let it run at most once per day: require __DIR__.'/prune.php'; then call prune_maybe();

Where this helps: keeps your hosting clean, prevents giant log files, limits cache growth, and avoids “disk quota exceeded” surprises.

<?php
declare(strict_types=1);

/**
 * Tiny PHP Content Pruner (Drop-in)
 * - Deletes files older than N days from specified folders
 * - Safe extension allowlist
 * - Optional token-protected manual run
 * - Optional "maybe" mode: run at most once per day (no cron needed)
 *
 * QUICK START:
 *   1) Edit $PRUNE_DIRS to the folders you want to prune
 *   2) Edit $MAX_AGE_DAYS
 *   3) Upload as prune.php
 *   4) Run manually: /prune.php?token=CHANGE_ME
 *
 * OPTIONAL (no-cron auto):
 *   - In a frequently-hit page:
 *       require __DIR__ . '/prune.php';
 *       prune_maybe(); // runs at most once per day
 */

// ---------------- CONFIG ----------------
$TOKEN = 'CHANGE_ME';       // set to something random for manual runs
$MAX_AGE_DAYS = 30;         // delete files older than this
$ALLOWED_EXTS = ['log','txt','json','cache','tmp']; // only delete these extensions
$PRUNE_DIRS = [
  __DIR__ . '/cache',
  __DIR__ . '/logs',
  __DIR__ . '/tmp',
];

$DRY_RUN = false;           // true = report only (no deletes)
$RUN_AT_MOST_EVERY = 86400; // seconds; 86400 = once per day (for prune_maybe)
$STATE_FILE = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'prune_last_run.txt';
// ----------------------------------------

function prune_realpath_safe(string $path): ?string {
  $rp = realpath($path);
  return $rp === false ? null : $rp;
}

function prune_is_under(string $child, string $parent): bool {
  $child = rtrim($child, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
  $parent = rtrim($parent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
  return strncmp($child, $parent, strlen($parent)) === 0;
}

function prune_run(array $dirs, int $maxAgeDays, array $allowedExts, bool $dryRun = false): array {
  $now = time();
  $cutoff = $now - ($maxAgeDays * 86400);

  $allowed = array_flip(array_map('strtolower', $allowedExts));

  $deleted = 0;
  $scanned = 0;
  $bytesFreed = 0;
  $errors = [];

  foreach ($dirs as $dir) {
    $base = prune_realpath_safe($dir);
    if ($base === null || !is_dir($base)) {
      $errors[] = "Skip (missing dir): {$dir}";
      continue;
    }

    $it = new RecursiveIteratorIterator(
      new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
      RecursiveIteratorIterator::CHILD_FIRST
    );

    foreach ($it as $f) {
      $path = (string)$f->getPathname();
      $rp = prune_realpath_safe($path);
      if ($rp === null) continue;

      // Safety: never delete outside the configured base dir
      if (!prune_is_under($rp, $base)) continue;

      if ($f->isFile()) {
        $scanned++;

        $ext = strtolower(pathinfo($rp, PATHINFO_EXTENSION));
        if ($ext === '' || !isset($allowed[$ext])) continue;

        $mtime = (int)$f->getMTime();
        if ($mtime > $cutoff) continue;

        $size = (int)$f->getSize();

        if ($dryRun) {
          // report only
          $deleted++;
          $bytesFreed += $size;
          continue;
        }

        if (@unlink($rp)) {
          $deleted++;
          $bytesFreed += $size;
        } else {
          $errors[] = "Failed delete: {$rp}";
        }
      }
    }
  }

  return [
    'scanned' => $scanned,
    'deleted' => $deleted,
    'bytes_freed' => $bytesFreed,
    'errors' => $errors,
    'cutoff_iso' => gmdate('c', $cutoff),
    'dry_run' => $dryRun,
  ];
}

/**
 * Runs prune at most once per $RUN_AT_MOST_EVERY seconds.
 * Use this if you want "no cron" maintenance.
 */
function prune_maybe(): void {
  $GLOBALS['__PRUNE_ALREADY_RAN'] = $GLOBALS['__PRUNE_ALREADY_RAN'] ?? false;
  if ($GLOBALS['__PRUNE_ALREADY_RAN']) return; // avoid double-run per request
  $GLOBALS['__PRUNE_ALREADY_RAN'] = true;

  $stateFile = (string)($GLOBALS['STATE_FILE'] ?? '');
  $every = (int)($GLOBALS['RUN_AT_MOST_EVERY'] ?? 86400);

  $last = 0;
  if ($stateFile !== '' && is_file($stateFile)) {
    $raw = trim((string)@file_get_contents($stateFile));
    if ($raw !== '' && ctype_digit($raw)) $last = (int)$raw;
  }

  if (time() - $last < $every) return;

  $res = prune_run(
    (array)$GLOBALS['PRUNE_DIRS'],
    (int)$GLOBALS['MAX_AGE_DAYS'],
    (array)$GLOBALS['ALLOWED_EXTS'],
    (bool)$GLOBALS['DRY_RUN']
  );

  // Best effort write
  if ($stateFile !== '') {
    @file_put_contents($stateFile, (string)time(), LOCK_EX);
  }

  // Optional: log result somewhere
  // error_log('Prune: ' . json_encode($res));
}

// ---- Manual run endpoint (optional) ----
if (PHP_SAPI !== 'cli') {
  $tok = $_GET['token'] ?? '';
  $manual = isset($_GET['run']) || isset($_GET['token']);

  if ($manual) {
    if (!is_string($tok) || $tok !== $GLOBALS['TOKEN']) {
      http_response_code(403);
      header('Content-Type: text/plain; charset=utf-8');
      echo "Forbidden. Provide ?token=YOUR_TOKEN\n";
      exit;
    }

    $res = prune_run($PRUNE_DIRS, $MAX_AGE_DAYS, $ALLOWED_EXTS, $DRY_RUN);

    header('Content-Type: text/plain; charset=utf-8');
    echo "Tiny Pruner\n";
    echo "Cutoff: {$res['cutoff_iso']}\n";
    echo "Scanned files: {$res['scanned']}\n";
    echo ($DRY_RUN ? "Would delete: " : "Deleted: ") . "{$res['deleted']}\n";
    echo "Bytes " . ($DRY_RUN ? "to free" : "freed") . ": {$res['bytes_freed']}\n";
    if (!empty($res['errors'])) {
      echo "\nErrors:\n- " . implode("\n- ", $res['errors']) . "\n";
    }
    exit;
  }
}
?>

Comments (0)

No comments yet — be the first.

← Back to all scripts