Skip to content

Instantly share code, notes, and snippets.

@EhsanCh
Last active September 16, 2026 18:18
Show Gist options
  • Select an option

  • Save EhsanCh/0396fb0768fea0a3d82aea9d6e83e90b to your computer and use it in GitHub Desktop.

Select an option

Save EhsanCh/0396fb0768fea0a3d82aea9d6e83e90b to your computer and use it in GitHub Desktop.
WHMCS Automated Incremental Patch Updater

WHMCS Automated Incremental Patch Updater

A zero-configuration, production-ready Bash script to automatically download, back up, and apply official WHMCS maintenance patches.

Features

  • Zero Setup: Auto-detects custom admin directory, system URL, and file permissions from configuration.php.
  • Dynamic Upgrade Chain: Detects current database version and queries the official WHMCS API to calculate required patch steps.
  • Smart Automated Backup: Creates pre-upgrade snapshots (compressed MySQL dump + core files, excluding uploads/cache).
  • Interactive Migration Trigger: Pauses between updates, providing the exact admin URL to finalize database schema upgrades.

Quick Installation & Run

Run the following commands directly on your server:

# 1. Download script
curl -sSL https://gist.github.com/EhsanCh/0396fb0768fea0a3d82aea9d6e83e90b/raw/whmcs-patch-updater.sh -o whmcs-patch-updater.sh

# 2. Grant execute permission
chmod +x whmcs-patch-updater.sh

# 3. Run updater
sudo ./whmcs-patch-updater.sh

One-Liner (Optional)

curl -sSL https://gist.github.com/EhsanCh/0396fb0768fea0a3d82aea9d6e83e90b/raw/whmcs-patch-updater.sh | sudo bash

How It Works

  1. Enter Directory: Specify your WHMCS root path (e.g. /home/username/public_html).
  2. Safety Backup: Choose to let the script generate a full MySQL dump and core file backup into whmcs_backup_YYYYMMDD_HHMMSS/.
  3. Patch & Migrate: The script sequentially downloads each release, synchronizes files, and displays your admin link. Open the link in your browser to apply the database schema update, then hit Enter to proceed to the next release.

License

This project is licensed under the MIT License.

MIT License

Copyright (c) 2026 Ehsan Chavoshi

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

#!/usr/bin/env bash
set -e
set -o pipefail
SCRIPT_DIR="$(pwd)"
echo "======================================================"
echo " WHMCS Fully Automated Incremental Patch Updater "
echo "======================================================"
echo ""
# 1. Dependency check & PHP Binary Detection
for cmd in curl wget unzip rsync stat tar mysqldump; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: Required command '$cmd' is not installed."
exit 1
fi
done
# Auto-detect best PHP binary (supporting cPanel / DirectAdmin / CloudLinux paths)
PHP_BIN="php"
for candidate in php /usr/local/bin/php /opt/cpanel/ea-php82/root/usr/bin/php /opt/cpanel/ea-php81/root/usr/bin/php /usr/bin/php8.2 /usr/bin/php8.1; do
if command -v "$candidate" &>/dev/null; then
if "$candidate" -m 2>/dev/null | grep -qiE 'pdo_mysql|mysqli'; then
PHP_BIN="$candidate"
break
fi
fi
done
# 2. Get installation directory
read -r -p "Enter WHMCS installation path (e.g. /home/username/public_html): " WHMCS_PATH </dev/tty
WHMCS_PATH="${WHMCS_PATH%/}"
if [ ! -d "$WHMCS_PATH" ] || [ ! -f "$WHMCS_PATH/configuration.php" ]; then
echo "Error: Directory does not contain a valid configuration.php file."
exit 1
fi
# 3. Detect file owner and group automatically
FILE_OWNER=$(stat -c '%U:%G' "$WHMCS_PATH/configuration.php" 2>/dev/null || stat -f '%Su:%Sg' "$WHMCS_PATH/configuration.php" 2>/dev/null || true)
# 4. Extract configuration and DB details safely via PHP
ENV_INFO=$("$PHP_BIN" -r '
$path = $argv[1];
chdir($path);
if (!file_exists("configuration.php")) { exit(1); }
// Read customadminpath directly even if DB fails
$configContent = file_get_contents("configuration.php");
$adminDir = "admin";
if (preg_match("/\\\$customadminpath\s*=\s*[\x27\x22]([^\x27\x22]+)[\x27\x22]/", $configContent, $m)) {
$adminDir = trim($m[1]);
}
include "configuration.php";
$version = "";
$systemUrl = "";
$db_host = $db_host ?? "localhost";
$db_port = $db_port ?? 3306;
$db_name = $db_name ?? "";
$db_username = $db_username ?? "";
$db_password = $db_password ?? "";
// 1. Try PDO MySQL
if (extension_loaded("pdo_mysql")) {
try {
$pdo = new PDO("mysql:host={$db_host};port={$db_port};dbname={$db_name};charset=utf8", $db_username, $db_password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 3
]);
$stmt = $pdo->query("SELECT value FROM tblconfiguration WHERE setting = '\''Version'\'' LIMIT 1");
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $version = trim($row["value"]); }
$stmt = $pdo->query("SELECT value FROM tblconfiguration WHERE setting = '\''SystemURL'\'' LIMIT 1");
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $systemUrl = rtrim(trim($row["value"]), "/"); }
} catch (\Throwable $e) {}
}
// 2. Fallback to mysqli if PDO failed or not loaded
if (empty($version) && extension_loaded("mysqli")) {
try {
$mysqli = @new mysqli($db_host, $db_username, $db_password, $db_name, (int)$db_port);
if (!$mysqli->connect_error) {
$res = $mysqli->query("SELECT value FROM tblconfiguration WHERE setting = '\''Version'\'' LIMIT 1");
if ($row = $res->fetch_assoc()) { $version = trim($row["value"]); }
$res = $mysqli->query("SELECT value FROM tblconfiguration WHERE setting = '\''SystemURL'\'' LIMIT 1");
if ($row = $res->fetch_assoc()) { $systemUrl = rtrim(trim($row["value"]), "/"); }
$mysqli->close();
}
} catch (\Throwable $e) {}
}
echo "ADMIN_DIR=" . escapeshellarg($adminDir) . "\n";
echo "CURRENT_VER=" . escapeshellarg($version) . "\n";
echo "SYSTEM_URL=" . escapeshellarg($systemUrl) . "\n";
echo "DB_HOST=" . escapeshellarg($db_host) . "\n";
echo "DB_PORT=" . escapeshellarg($db_port) . "\n";
echo "DB_NAME=" . escapeshellarg($db_name) . "\n";
echo "DB_USER=" . escapeshellarg($db_username) . "\n";
echo "DB_PASS=" . escapeshellarg($db_password) . "\n";
' "$WHMCS_PATH" 2>/dev/null || true)
eval "$ENV_INFO"
ADMIN_DIR="${ADMIN_DIR:-admin}"
CURRENT_VER=$(echo "$CURRENT_VER" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' || true)
if [ -z "$CURRENT_VER" ]; then
echo "Warning: Failed to auto-detect installed WHMCS version from database."
read -r -p "Enter current version manually (e.g. 8.13.1): " CURRENT_VER </dev/tty
CURRENT_VER="${CURRENT_VER#[vV]}"
fi
if [ -n "$SYSTEM_URL" ]; then
ADMIN_URL="${SYSTEM_URL}/${ADMIN_DIR}/index.php"
else
ADMIN_URL="(Relative) /${ADMIN_DIR}/index.php"
fi
echo ""
echo "Detected Environment:"
echo "------------------------------------------------------"
echo " Installation Path : $WHMCS_PATH"
echo " Custom Admin Path : $ADMIN_DIR"
echo " File Permissions : ${FILE_OWNER:-Not Detected}"
echo " Current Version : v$CURRENT_VER"
echo " Admin Panel URL : $ADMIN_URL"
echo " Database Name : $DB_NAME"
echo "------------------------------------------------------"
# 5. Fetch available patches from WHMCS API
API_URL="https://www.whmcs.com/assets/scripts/get-downloads.php"
echo "Fetching official patch catalog..."
TMP_DIR=$(mktemp -d -t whmcs_patch_XXXXXX)
cleanup() {
echo ""
echo "Cleaning up temporary files..."
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
}
trap cleanup EXIT INT TERM
API_JSON="$TMP_DIR/downloads.json"
curl -sSL "$API_URL" -o "$API_JSON"
if [ ! -s "$API_JSON" ]; then
echo "Error: Unable to fetch patch catalog from $API_URL"
exit 1
fi
# 6. Calculate upgrade plan safely by disabling 'set -e' temporarily
set +e
UPGRADE_PLAN=$("$PHP_BIN" -r '
$jsonFile = $argv[1];
$currentVer = $argv[2];
$data = json_decode(file_get_contents($jsonFile), true);
if (!$data || !isset($data["patchSets"])) { exit(1); }
$parts = explode(".", $currentVer);
if (count($parts) < 2) { exit(2); }
$branch = $parts[0] . "." . $parts[1];
if (!isset($data["patchSets"][$branch])) { exit(3); }
$versions = $data["patchSets"][$branch]["versions"] ?? [];
$versions = array_reverse($versions);
$chain = [];
$pointer = $currentVer;
foreach ($versions as $item) {
$from = trim(ltrim($item["compatibleWith"], "vV"));
$to = trim(ltrim($item["version"], "vV"));
$url = $item["downloadUrl"];
if ($from === $pointer) {
$chain[] = "{$from}|{$to}|{$url}";
$pointer = $to;
}
}
if (empty($chain)) { exit(4); }
echo implode("\n", $chain);
' "$API_JSON" "$CURRENT_VER" 2>/dev/null)
PLAN_STATUS=$?
set -e
if [ $PLAN_STATUS -eq 3 ]; then
echo "Error: Branch for version $CURRENT_VER is not present in official patchsets."
exit 1
elif [ $PLAN_STATUS -eq 4 ] || [ -z "$UPGRADE_PLAN" ]; then
echo "Your WHMCS is already up to date for this branch (v$CURRENT_VER). No patches needed."
exit 0
elif [ $PLAN_STATUS -ne 0 ]; then
echo "Error: Failed to calculate upgrade plan due to an unexpected JSON/PHP issue."
exit 1
fi
IFS=$'\n' read -rd '' -a PATCHES_TO_APPLY <<< "$UPGRADE_PLAN" || true
TOTAL_STEPS=${#PATCHES_TO_APPLY[@]}
LAST_ITEM="${PATCHES_TO_APPLY[$((TOTAL_STEPS - 1))]}"
TARGET_VER=$(echo "$LAST_ITEM" | awk -F'|' '{print $2}')
echo ""
echo "Upgrade Chain Found:"
for item in "${PATCHES_TO_APPLY[@]}"; do
IFS="|" read -r from_ver to_ver _ <<< "$item"
echo " -> Upgrade from v$from_ver to v$to_ver"
done
echo ""
# 7. Backup Prompt and Execution
echo "******************************************************"
echo " WARNING "
echo " Modifying core files and applying patches carries "
echo " risks. Always maintain a full backup before updating."
echo "******************************************************"
read -r -p "Have you already backed up your files and database? [y/N]: " HAS_BACKUP </dev/tty
DO_AUTO_BACKUP="n"
if [[ ! "$HAS_BACKUP" =~ ^[yY]$ ]]; then
echo ""
read -r -p "Would you like this script to create a backup now before continuing? [Y/n]: " DO_AUTO_BACKUP </dev/tty
DO_AUTO_BACKUP="${DO_AUTO_BACKUP:-y}"
fi
if [[ "$DO_AUTO_BACKUP" =~ ^[yY]$ ]]; then
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_DIR="${SCRIPT_DIR}/whmcs_backup_${TIMESTAMP}"
mkdir -p "$BACKUP_DIR"
echo ""
echo "Starting pre-flight backup to: $BACKUP_DIR"
# Backup Database
echo "Dumping MySQL database ($DB_NAME)..."
MYSQL_PWD="$DB_PASS" mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" "$DB_NAME" | gzip > "${BACKUP_DIR}/database_${DB_NAME}_${TIMESTAMP}.sql.gz"
if [ ! -s "${BACKUP_DIR}/database_${DB_NAME}_${TIMESTAMP}.sql.gz" ]; then
echo "Error: Database backup failed or produced an empty file."
exit 1
fi
# Backup Files (Excluding attachments, downloads, and cache safely)
echo "Archiving WHMCS core files..."
tar -czf "${BACKUP_DIR}/whmcs_files_${TIMESTAMP}.tar.gz" \
--exclude='./attachments/*' \
--exclude='./downloads/*' \
--exclude='./templates_c/*' \
-C "$WHMCS_PATH" .
# Restore ownership of backup directory if root was used
if [ -n "$FILE_OWNER" ]; then
chown -R "$FILE_OWNER" "$BACKUP_DIR"
fi
echo "Backup completed successfully:"
echo " - ${BACKUP_DIR}/database_${DB_NAME}_${TIMESTAMP}.sql.gz"
echo " - ${BACKUP_DIR}/whmcs_files_${TIMESTAMP}.tar.gz"
echo ""
elif [[ ! "$HAS_BACKUP" =~ ^[yY]$ ]]; then
echo "Aborted: You must have a verified backup before applying patches."
exit 1
fi
read -r -p "Proceed with upgrading from v$CURRENT_VER to v$TARGET_VER? [y/N]: " CONFIRM </dev/tty
if [[ ! "$CONFIRM" =~ ^[yY]$ ]]; then
echo "Upgrade canceled."
exit 0
fi
cd "$TMP_DIR"
# 8. Apply patches sequentially
STEP=1
for item in "${PATCHES_TO_APPLY[@]}"; do
IFS="|" read -r from_ver to_ver dl_url <<< "$item"
ZIP_NAME="patch_${from_ver}_to_${to_ver}.zip"
echo ""
echo "======================================================"
echo "[$STEP/$TOTAL_STEPS] Applying patch: v$from_ver -> v$to_ver"
echo "======================================================"
echo "Downloading patch file..."
wget -q --show-progress "$dl_url" -O "$ZIP_NAME"
rm -rf whmcs
echo "Extracting patch..."
unzip -q -o "$ZIP_NAME"
if [ ! -d "whmcs" ]; then
echo "Error: Unexpected archive structure. 'whmcs' directory not found in patch."
exit 1
fi
# Rename admin directory cleanly
if [ -d "whmcs/admin" ] && [ "$ADMIN_DIR" != "admin" ]; then
echo "Renaming patch admin directory to '$ADMIN_DIR'..."
rm -rf "whmcs/$ADMIN_DIR"
mv whmcs/admin "whmcs/$ADMIN_DIR"
fi
# Set ownership on temporary directory beforehand (lightning fast)
if [ -n "$FILE_OWNER" ]; then
chown -R "$FILE_OWNER" whmcs/
fi
# Synchronize updated files
echo "Syncing files to WHMCS installation..."
rsync -a --remove-source-files whmcs/ "$WHMCS_PATH/"
rm -f "$ZIP_NAME"
echo ""
echo "[Action Required]"
echo "Open the admin panel to trigger database migration for v$to_ver:"
echo ">>> $ADMIN_URL <<<"
echo ""
read -r -p "Press [Enter] after the admin dashboard loads successfully to continue..." </dev/tty
STEP=$((STEP + 1))
done
echo ""
echo "======================================================"
echo " Upgrade Complete!"
echo " WHMCS has been updated from v$CURRENT_VER to v$TARGET_VER."
echo "======================================================"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment