# cPanel Cron Jobs: PHP Paths, Output Emails, and Monitoring

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cpanel-cron-jobs

> Set up cPanel cron jobs that work: which PHP binary to call, why plain php runs php-cgi, how to stop cron emails, host limits, server time, and monitoring.

**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](https://docs.cpanel.net/cpanel/advanced/cron-jobs/)):

- **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)](https://man7.org/linux/man-pages/man5/crontab.5.html)), 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](https://docs.cpanel.net/ea4/php/easyapache4-and-the-ea-php-cli-package/), [file system layout](https://docs.cpanel.net/ea4/basics/easyapache-4-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](https://docs.cpanel.net/installation-guide/system-requirements/)). cronie starts jobs with `/usr/bin:/bin`, plus `/usr/sbin:/sbin` in cronie 1.7 and later ([cron environment variables](https://cronradar.com/blog/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](https://www.php.net/manual/en/features.commandline.differences.php)):

- **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 -q` suppresses 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](https://github.com/php/php-src/blob/php-8.4.27/sapi/cli/php_cli.c#L116-L122)); php-cgi uses php.ini's value, 30 seconds by default ([ini reference](https://www.php.net/manual/en/info.configuration.php#ini.max-execution-time)).
- **Command-line tools refuse it.** WP-CLI 2.12's phar starts through `#!/usr/bin/env php` ([make-phar.php](https://github.com/wp-cli/wp-cli-bundle/blob/v2.12.0/utils/make-phar.php#L308)) and exits with "WP-CLI only works correctly from the command line, using the 'cli' PHP SAPI" under any other build ([boot-phar.php](https://github.com/wp-cli/wp-cli-bundle/blob/v2.12.0/php/boot-phar.php#L3-L10)).

### 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](https://github.com/CpanelInc/ea-php-cli/blob/6e9d6f14051aec5f86132b6799a95b0d04e08b77/SOURCES/ea_php_cli.pm#L34-L84), [lookup](https://github.com/CpanelInc/ea-php-cli/blob/6e9d6f14051aec5f86132b6799a95b0d04e08b77/SOURCES/ea_php_cli.pm#L224-L261)).

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](https://docs.cpanel.net/cpanel/software/multiphp-manager-for-cpanel/) 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](https://www.php.net/manual/en/configuration.file.php)) or any `.user.ini` file ([.user.ini](https://www.php.net/manual/en/configuration.file.per-user.php)), and those two files, with `.htaccess`, are where cPanel's MultiPHP INI Editor saves a domain's settings ([docs](https://docs.cpanel.net/cpanel/software/multiphp-ini-editor-for-cpanel/)). 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](https://docs.cloudlinux.com/cloudlinuxos/cloudlinux_os_components/)), which cron never reads, so `php -v` in cPanel's [Terminal](https://docs.cpanel.net/cpanel/advanced/terminal-in-cpanel/) 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:

```text
*/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>&1
```

`date` 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](https://www.php.net/manual/en/function.include.php)). 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 as `cron.php`, then `cron.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](https://www.gnu.org/software/wget/manual/wget.html)). `wget -q -O -` prints the page, so cron mails it. Use `wget -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](https://curl.se/docs/manpage.html)): `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_abort` is on ([connection handling](https://www.php.net/manual/en/features.connection-handling.php)), so call `ignore_user_abort(true)` at the top.

## Stop Cron Email Without Losing Errors

Cron mails whatever a job prints ([cron(8)](https://man7.org/linux/man-pages/man8/cron.8.html)), 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):

```text
0 6 * * * /usr/local/bin/ea-php82 /home/exampleuser/cron/reminders.php >> /home/exampleuser/logs/reminders.log 2>&1
```

This 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](https://kb.hosting.com/docs/cron-jobs-reset-to-15-minute-intervals)), and Namecheap doesn't allow intervals under 5 minutes or more than 5 simultaneous cron jobs on its shared servers ([Namecheap](https://www.namecheap.com/support/knowledgebase/article.aspx/1212/22/do-you-allow-cron-jobs/)). 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L297-L306)), 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](https://docs.cloudlinux.com/cloudlinuxos/cloudlinux_os_components/)). Its limits apply to them ([CloudLinux limits](https://docs.cloudlinux.com/cloudlinuxos/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](https://docs.cloudlinux.com/cloudlinuxos/cloudlinux_os_components/)). 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](https://cronradar.com/blog/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](https://cronradar.com/blog/wordpress-cron-monitoring) has the steps. On cPanel, start WP-CLI with a CLI binary so its `env php` line never runs:

```text
*/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>&1
```

`wget -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:

```text
* * * * * cd /home/exampleuser/laravel && /usr/local/bin/ea-php82 artisan schedule:run >> /home/exampleuser/logs/scheduler.log 2>&1
```

Scheduled commands inherit that binary ([php_binary()](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Support/functions.php#L38-L41)). [Laravel schedule not running](https://cronradar.com/blog/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:

```bash
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:

```bash
#!/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:

```text
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>&1
```

`nightly-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](https://docs.cronradar.com/schedules)). Convert with GNU `date`, using the zone the test job showed:

```bash
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:15
```

When 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 `fail` check-in opens an incident immediately, with the exit code in its message. `timeout 30m` turns a hang into exit code 124 ([timeout(1)](https://man7.org/linux/man-pages/man1/timeout.1.html)), so a hang becomes a `fail`.
- **Missed:** the overdue check runs once a minute. A monitor is `warning` once its expected time passes and becomes `critical`, 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 `start` with no `complete` or `fail` goes `critical` once 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's `start` resets it, and a hang is caught only when the job then goes silent. There's no per-job maximum runtime; the `timeout` is what catches a hang the same night. If CloudLinux kills the PHP process, the wrapper reports `fail`; if it kills the wrapper too, the run leaves a `start` with 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
<?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](https://cronradar.com/blog/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.
