cPanel Cron Jobs: PHP Paths, Output Emails, and Monitoring
Contents
To run a PHP script from a cPanel cron job, put a command-line PHP binary and the script in the Command field with full paths, such as /usr/local/bin/ea-php82 /home/exampleuser/cron/reminders.php, and append the output to a log file. A bare php in cron finds /usr/bin/php, which cPanel's EasyApache 4 maps to the CGI build of PHP: it adds HTTP headers to the output, so cron emails you after every run, and it keeps php.ini's time limit. Cron also starts in your home directory, reads the schedule in the server's time zone, and tells nobody when a run fails or never happens. Checked on October 25, 2026 against the cPanel & WHM documentation (versions 82 through current), the ea-php-cli 1.0.0-12 source, and PHP 8.4.27.
The Cron Jobs Page#
Advanced » Cron Jobs in cPanel has two parts (cPanel docs):
- Cron Email: the address that receives cron's mail. Removing it turns mail off for every job.
- Add New Cron Job: a Common Settings menu of presets, such as Once Per Hour (
0 * * * *), that fills in the Minute, Hour, Day, Month, and Weekday boxes, plus a Command box. All six are required.
Saved jobs appear under Current Cron Jobs with Edit and Delete. The boxes are the five standard cron fields, matched against the server's clock. If the page is missing, your host controls it through WHM's Feature Manager.
cPanel says to use absolute paths and make custom scripts executable. Also keep % out of the Command box: cron turns an unescaped % into a newline (crontab(5)), so put date +%F and URL-encoded strings in a script. The page keeps no run history, so nothing in it shows that a job ran.
In the examples below, the first five fields of each line go in the timing boxes and the rest goes in Command.
Which PHP Runs: php, /usr/local/bin/php, or ea-php82#
EasyApache 4 replaces the system's PHP with wrappers from its ea-php-cli package (ea-php-cli, file system layout):
| Command | What it runs |
|---|---|
php, /usr/bin/php |
php-cgi, version chosen by path |
/usr/local/bin/php |
php-cli, version chosen by path |
/usr/local/bin/ea-php82 |
PHP 8.2's php-cli, always |
/opt/cpanel/ea-php82/root/usr/bin/php |
PHP 8.2's php-cli binary itself |
/usr/bin/ea-php82 |
PHP 8.2's php-cgi, always |
A bare php reaches the CGI wrapper because cron's default PATH usually doesn't include /usr/local/bin. cPanel runs on AlmaLinux, Rocky Linux, and CloudLinux, which use cronie, and on Ubuntu, which uses Debian's cron (system requirements). cronie starts jobs with /usr/bin:/bin, plus /usr/sbin:/sbin in cronie 1.7 and later (cron environment variables). Ubuntu 24.04 and later instead pass on the daemon's PATH, which includes /usr/local/bin, so there a bare php finds the version-picking CLI wrapper described next. Naming the binary works the same everywhere.
The CGI build differs from the command-line one (PHP manual):
- It prints HTTP headers, such as
Content-type: text/html; charset=UTF-8, before the script's output, so every run produces output and cron mails it.php -qsuppresses them; the other differences remain. - It changes into the script's directory, unless you pass
-C. - It keeps the time limit. The CLI hardcodes
max_execution_time=0, no limit (php_cli.c); php-cgi uses php.ini's value, 30 seconds by default (ini reference). - Command-line tools refuse it. WP-CLI 2.12's phar starts through
#!/usr/bin/env php(make-phar.php) and exits with "WP-CLI only works correctly from the command line, using the 'cli' PHP SAPI" under any other build (boot-phar.php).
The Version /usr/local/bin/php Picks#
The wrapper starts from the directory of the first argument that is an existing file, or from the current directory if there is none, and walks up toward your home directory. The first domain document root it meets decides the version, as set in MultiPHP Manager. If it reaches your home directory first, you get the server's default version (ea_php_cli.pm, lookup).
A script in public_html/cron/ gets the main domain's version. A script in ~/bin, or a Laravel app in ~/laravel whose document root is ~/laravel/public, gets the server default, which may be older than the version you chose for the site. So name the version: /usr/local/bin/ea-php82, with the number MultiPHP Manager shows for the domain, and update your cron jobs when you change it.
Settings can differ too. The CLI doesn't read a php.ini in the current directory (PHP manual) or any .user.ini file (.user.ini), and those two files, with .htaccess, are where cPanel's MultiPHP INI Editor saves a domain's settings (docs). A memory_limit you raised for the site may not reach its cron job; pass what the job needs, as in /usr/local/bin/ea-php82 -d memory_limit=512M script.php.
On CloudLinux servers with CloudLinux's PHP Selector, the selector adds its PHP to your PATH through ~/.bashrc (CloudLinux docs), which cron never reads, so php -v in cPanel's Terminal can differ from what cron gets.
Test What Cron Actually Gets#
Add this as a temporary job, let it run once, read ~/cron-test.log, and delete the job:
*/15 * * * * { date; pwd; echo "PATH=$PATH"; command -v php; command -v curl; command -v timeout; php -v; /usr/local/bin/php -v; } >> "$HOME/cron-test.log" 2>&1date shows the server's clock and zone, pwd shows where jobs start, and each -v line names the build in parentheses: (cli) or (cgi-fcgi). If curl has no path, use the PHP fallback at the end; if timeout has none, drop it from the wrapper's cron line.
Cron Starts in Your Home Directory#
cronie and Debian's cron start each job in the crontab owner's home directory, which is why cPanel's example command ./my-script.sh works. Web scripts often assume otherwise. PHP's include 'config.php' still works, because PHP falls back to the including script's directory, but include '../lib/db.php', fopen('data/export.csv', 'w'), and other relative paths resolve against the current directory (include). Build paths from __DIR__, as in __DIR__ . '/data/export.csv', or cd first.
Run the Script, or Request a URL?#
The common alternative is to request the script's URL with wget or curl. Running the script with the CLI avoids most of the trouble:
/usr/local/bin/ea-php82 script.php |
wget or curl to https://example.com/cron.php |
|
|---|---|---|
| Time limit | None by default | max_execution_time, plus web server and proxy timeouts |
| PHP version, ini | The binary you name, CLI settings | The site's MultiPHP version and INI settings |
| Who can start it | Cron; the script can sit outside the site | Anyone who finds the URL |
| What reports errors | The script's exit code | The HTTP status, if the client checks it |
If the job only works as a web request:
- Tame wget. Without
-O, each run saves the response in your home directory ascron.php, thencron.php.1,cron.php.2, and so on. It makes up to 20 tries, and when no data arrives for 900 seconds, its default read timeout, it restarts the download, which requests the URL and can start the job again (GNU Wget 1.25 manual).wget -q -O -prints the page, so cron mails it. Usewget -q -O /dev/null --tries=1 --timeout=300 URL; it exits 8 when the server returns an error. - Make curl fail. curl treats an HTTP error as success unless you pass
--fail, which makes a 4xx or 5xx response exit 22 (curl manual):curl --fail --silent --show-error --max-time 300 --output /dev/null URL. - Protect the URL with a long secret in the query string that the script checks.
- Keep it running. PHP aborts a web script whose client disconnected the next time the script sends output, unless
ignore_user_abortis on (connection handling), so callignore_user_abort(true)at the top.
Stop Cron Email Without Losing Errors#
Cron mails whatever a job prints (cron(8)), and cPanel sends it to the Cron Email address. A silent job sends nothing; a job every 5 minutes that prints anything, even CGI headers, sends 288 emails a day.
To silence one job, cPanel's documentation says to append /dev/null 2>&1, but its example leaves out the >. The working suffix is >/dev/null 2>&1. Without the >, /dev/null becomes an argument to your command and the output is still mailed. Either way, discarding output discards the errors too. Append to a log file in your home directory instead (create ~/logs first):
0 6 * * * /usr/local/bin/ea-php82 /home/exampleuser/cron/reminders.php >> /home/exampleuser/logs/reminders.log 2>&1This file is your record of what each run printed; it grows until you trim it.
Host Limits: Intervals, Processes, and Memory#
Hosts add rules of their own. Two examples, checked October 25, 2026: hosting.com resets shared and reseller cron jobs that run more often than every 15 minutes to 15-minute intervals (hosting.com), and Namecheap doesn't allow intervals under 5 minutes or more than 5 simultaneous cron jobs on its shared servers (Namecheap). Read your host's policy before scheduling anything frequent.
A floor like that changes more than frequency for Laravel. schedule:run starts only the tasks due in the minute it runs (Event.php), so if it can run only every 15 minutes, a task set for 02:05 never runs at all.
On CloudLinux servers, cron jobs run inside your account's LVE, the same resource container as your website (pam_lve for crond). Its limits apply to them (CloudLinux limits):
- Physical memory (PMEM): over the limit, CloudLinux kills processes in the LVE. A killed job leaves no PHP error behind.
- Processes (NPROC) and entry processes (EP): at the process limit, no new process starts, so the job can't launch. CloudLinux describes EP as covering concurrent web scripts, SSH sessions, and cron jobs.
- CPU (SPEED) and I/O: these throttle instead of kill, so a heavy job runs longer.
cPanel's Resource Usage page on these servers shows when the account hit a limit (CloudLinux docs). cPanel also warns that cron starts the next run even if the previous one hasn't finished, so leave room between runs.
Cron Uses the Server's Time Zone#
The Minute and Hour boxes follow the server's clock, which your host sets and the test job's date line shows. It may not be your zone. Cron timezone covers how cron handles zones and daylight saving time, and the monitoring section below converts a server-local time to UTC.
WordPress and Laravel on cPanel#
WordPress. Set DISABLE_WP_CRON and run due events from a cPanel cron job; how to monitor WordPress cron jobs has the steps. On cPanel, start WP-CLI with a CLI binary so its env php line never runs:
*/15 * * * * /usr/local/bin/ea-php82 /home/exampleuser/bin/wp-cli.phar --path=/home/exampleuser/public_html cron event run --due-now --quiet >> /home/exampleuser/logs/wp-cron.log 2>&1wget -q -O /dev/null "https://example.com/wp-cron.php?doing_wp_cron" also works and runs under the site's PHP settings.
Laravel. Laravel's scheduler needs schedule:run every minute, which some hosts don't allow. Name the PHP version, because /usr/local/bin/php in ~/laravel gets the server default:
* * * * * cd /home/exampleuser/laravel && /usr/local/bin/ea-php82 artisan schedule:run >> /home/exampleuser/logs/scheduler.log 2>&1Scheduled commands inherit that binary (php_binary()). Laravel schedule not running covers the scheduler's other failure causes.
Monitor cPanel Cron Jobs With a Wrapper Script#
Nothing in cPanel tells you a job failed, was killed, or stopped running; an outside monitor that expects a check-in from each run does. With CronRadar, that check-in is HTTP. There's no CronRadar SDK you can install for PHP or Laravel today (checked September 20, 2026), and nothing discovers cPanel cron jobs, so each job reports with its own check-in.
Set up a key file and a logs directory, over SSH or in cPanel's Terminal. Use one CronRadar application and key per environment:
mkdir -p ~/bin ~/logs
# Paste the API key, press Enter, then Ctrl-D. Only your account can read the file.
(umask 077 && cat > ~/.cronradar-key)Save this as ~/bin/monitored-cron.sh and run chmod 700 ~/bin/monitored-cron.sh. It reports start, runs the command, reports complete or fail, and exits with the command's own code. Each report gives up after five seconds and ignores errors, so a monitoring outage can't fail the job:
#!/bin/sh
# Usage: monitored-cron.sh MONITOR_KEY 'UTC SCHEDULE' GRACE_SECONDS COMMAND [ARGS...]
if [ "$#" -lt 4 ]; then
echo "usage: monitored-cron.sh key 'schedule' grace command [args...]" >&2
exit 64
fi
key=$1
schedule=$2
grace=$3
shift 3
url="https://cron.life/api/v1/ping/$key"
run_id="$key-$(date +%s)-$$"
CRONRADAR_API_KEY=$(cat "$HOME/.cronradar-key" 2>/dev/null) || CRONRADAR_API_KEY=
report() {
event=$1
shift
[ -n "$CRONRADAR_API_KEY" ] || return 0
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=$schedule" \
--data-urlencode "gracePeriod=$grace" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
"$@"
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=$key exited with code $rc"
fi
exit "$rc"A nightly export at 03:15 server time then looks like this:
15 3 * * * /home/exampleuser/bin/monitored-cron.sh nightly-export '15 8 * * *' 4500 /usr/bin/timeout 30m /usr/local/bin/ea-php82 /home/exampleuser/app/cron/export.php >> /home/exampleuser/logs/nightly-export.log 2>&1nightly-export is the monitor key. 15 8 * * * is the schedule in UTC, because CronRadar evaluates every schedule in UTC while the timing boxes are server-local (schedule syntax). Convert with GNU date, using the zone the test job showed:
date -u --date='TZ="America/Chicago" 2026-07-15 03:15' +%H:%M # 08:15
date -u --date='TZ="America/Chicago" 2026-01-15 03:15' +%H:%M # 09:15When the two differ, send the earlier time with a grace period of at least an hour plus the usual run: 4500 seconds for a run under 15 minutes. The cost is a missed run turning critical 75 minutes late in summer, and a warning for the hour before each run in winter. A server on UTC needs neither.
What CronRadar does with the reports:
- Failed: a
failcheck-in opens an incident immediately, with the exit code in its message.timeout 30mturns a hang into exit code 124 (timeout(1)), so a hang becomes afail. - Missed: the overdue check runs once a minute. A monitor is
warningonce its expected time passes and becomescritical, with an incident, when it is past its grace period and at least two minutes late. That covers a deleted entry, a job blocked at the process limit, and a host that changed your schedule. - Stuck: a
startwith nocompleteorfailgoescriticalonce the job's next scheduled time plus the grace period passes with no other check-in. Every check-in moves that time, so the next run'sstartresets it, and a hang is caught only when the job then goes silent. There's no per-job maximum runtime; thetimeoutis what catches a hang the same night. If CloudLinux kills the PHP process, the wrapper reportsfail; if it kills the wrapper too, the run leaves astartwith no finish.
schedule and gracePeriod apply only when a check-in creates the monitor, so editing them in the cron line later doesn't change an existing monitor; change it in the dashboard. Sending them every time means a deleted monitor is recreated by the next run. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.
Without the curl Command#
If the test job found no curl, report from the PHP script instead. This needs allow_url_fopen enabled. The stream timeout caps the connection and each read at five seconds, and every error is ignored:
<?php
function cronradar_report(string $event, array $query = []): void
{
$key = trim((string) @file_get_contents(getenv('HOME') . '/.cronradar-key'));
if ($key === '') {
return;
}
$query += ['schedule' => '15 8 * * *', 'gracePeriod' => 4500];
$url = 'https://cron.life/api/v1/ping/nightly-export/' . $event . '?'
. http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => ['Authorization: Basic ' . base64_encode($key . ':'), 'Content-Length: 0'],
'timeout' => 5,
'ignore_errors' => true,
]]);
try {
@file_get_contents($url, false, $context);
} catch (Throwable $e) {
// A monitoring problem must never stop the job.
}
}
$runId = 'nightly-export-' . time() . '-' . getmypid();
cronradar_report('start', ['run_id' => $runId]);
try {
run_nightly_export();
} catch (Throwable $e) {
cronradar_report('fail', ['run_id' => $runId, 'message' => 'nightly-export threw ' . get_class($e)]);
throw $e;
}
cronradar_report('complete', ['run_id' => $runId]);run_nightly_export() stands for the job's own code. Its exception is rethrown, so PHP still exits non-zero. A fatal error such as exhausted memory, or a kill by timeout, skips the catch block and leaves a start with no finish.
Frequently Asked Questions#
What PHP path should I use in a cPanel cron job?#
Use a version-specific command-line binary such as /usr/local/bin/ea-php82, matching the version MultiPHP Manager shows for the site. Plain php and /usr/bin/php run the CGI build, and /usr/local/bin/php falls back to the server's default version for scripts outside a document root. A test cron job that runs -v should print (cli).
How do I stop cPanel cron emails?#
Remove the Cron Email address to stop mail for every job, or end one job's command with >/dev/null 2>&1, including the > that cPanel's documentation example omits. Appending to a log file with >> /home/exampleuser/logs/job.log 2>&1 stops the mail and keeps the errors. If every run emails Content-type: text/html, the job runs php-cgi; switch to a CLI binary.
Why isn't my cPanel cron job running?#
Confirm the entry is under Current Cron Jobs, the command uses absolute paths and a CLI PHP binary, any script you call directly is executable, and the job's logged output shows no error. Then check your host's interval policy and, on CloudLinux, the Resource Usage page. Cron job not running covers the general causes.
What time zone do cPanel cron jobs use?#
The server's, which your host sets and which may not be yours. A test job that runs date shows it. Convert your intended time into that zone for the timing boxes, and into UTC for a monitor such as CronRadar.
Can I run a cPanel cron job every minute?#
Cron supports * * * * *, but some shared hosts don't: hosting.com resets anything more frequent than every 15 minutes, and Namecheap doesn't allow intervals under 5 minutes. Check your host's policy first. Laravel's scheduler needs every minute, so on a host with a 15-minute floor, schedule tasks on quarter hours or move to a VPS.
Put this guide to work
Connect your PHP job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The PHP SDK is temporarily unavailable on Packagist. Use manual HTTP reporting below. This setup does not discover jobs automatically.