This is one of those bugs that drives you crazy, because you copy the command, run it in the terminal, and it works perfectly — and the same command in a cron job does nothing.

On our server this happened twice. The first time with a stats cron job, the second time with sitemap generation. Both times the same cause.

The symptom#

In the cPanel terminal this works:

Bash
php84 artisan schedule:run

You put the same thing in cron:

Code
* * * * * cd /home/USER/app && php84 artisan schedule:run

And in the cron email or log you get this:

Code
/bin/sh: php84: command not found

The cause: php84 is not a binary#

Here is the key point, and it surprises many people: php84 on cPanel is usually not a program in the search path, it is an alias.

Check:

Bash
type php84

If you get an answer like this, that is the story:

Code
php84 is aliased to `/opt/cpanel/ea-php84/root/usr/bin/php'

Aliases are defined in the ~/.bashrc file. And that file is only read in an interactive shell — that is, when you are sitting at a terminal.

Cron is not an interactive shell. Cron usually runs the command with /bin/sh, the environment is almost empty, and ~/.bashrc is never read. So that alias does not exist at all in cron's world.

The same logic applies to these:

  • Functions defined in ~/.bashrc
  • Environment variables you set in ~/.bash_profile
  • PATH changes you wrote in profile files
  • The nvm environment and similar Node version managers

The fix: an absolute path#

No trick needed. Just write the full path of the binary.

First find it:

Bash
type php84
# or
ls -d /opt/cpanel/ea-php*/root/usr/bin/php

Then use that in cron:

Code
* * * * * cd /home/USER/app && /opt/cpanel/ea-php84/root/usr/bin/php artisan schedule:run >> /dev/null 2>&1

Test that it really works, but not in a normal terminal — in an environment that looks like cron:

Bash
env -i /bin/sh -c '/opt/cpanel/ea-php84/root/usr/bin/php -v'

The -i switch empties the environment, so if it answers here, it will answer in cron too. This is a real test, unlike running the same command in the terminal, which proves nothing.

Second trap: you see no output#

The common cron pattern is this:

Code
>> /dev/null 2>&1

which means "throw away both normal output and errors". It is fine for a cron job that works, but it is a disaster while debugging: if cron fails, no trace is left.

During setup, send the output to a file:

Code
* * * * * cd /home/USER/app && /opt/cpanel/ea-php84/root/usr/bin/php artisan schedule:run >> /home/USER/cron.log 2>&1

And then look at it:

Bash
tail -50 /home/USER/cron.log

A subtle point: 2>&1 must come after the main output redirection. If you swap them, errors go to the previous destination and do not land in the file.

Once you are sure everything is right, delete the log file or set up rotation for it; otherwise a per-minute cron job builds a big file within a few months.

Third trap: the current directory#

Cron starts from your home directory, not the project root. If you forget the cd, Laravel cannot find the .env file and you get a configuration error that is not obviously related to cron.

Two ways:

Code
# Way one: change directory
* * * * * cd /home/USER/app && /opt/.../php artisan schedule:run

# Way two: full path to the artisan file
* * * * * /opt/.../php /home/USER/app/artisan schedule:run

The second way is cleaner because it does not depend on the shell.

Fourth trap: the cron PHP version is not the web version#

cPanel has a "Cron Jobs" section that may run the command with the system's default interpreter — the old version. If your project was written for a newer version, cron breaks with a syntax error while the site works fine.

This is one symptom of the bigger problem we explained in the shared hosting checklist: on cPanel, the web version and the command-line version are two different things.

Laravel's scheduler and the allowed cron interval#

Laravel is designed so that you have one per-minute cron job and the rest of the scheduling happens in code:

PHP
Schedule::command('stats:rollup')->dailyAt('03:10');
Schedule::command('sitemap:build')->dailyAt('04:00');

If your host does not allow per-minute cron and sets the minimum to five minutes, you need to know what happens: tasks set for exactly a minute when cron does not run never run. A daily task at 3:10 only runs if cron wakes up exactly at 3:10.

Two practical solutions:

  • Align task times with the cron interval (for example, everything on multiples of five).
  • Or use ->everyFiveMinutes() and larger intervals that match each wake-up.

Fifth trap: the percent sign in cron#

This is one of those things you only hit once and remember forever.

In a crontab, the % character has a special meaning: cron converts it to a newline and feeds the rest of the line to the command as standard input. So this line:

Code
0 3 * * * mysqldump -u USER -pPASS DB | gzip > /home/USER/backup-$(date +%F).sql.gz

in practice becomes a command that ends at the + and the rest is never run. The result is a backup file with a strange name or a silent failure.

The fix: escape every %.

Code
0 3 * * * mysqldump -u USER -pPASS DB | gzip > /home/USER/backup-$(date +\%F).sql.gz

Or better, put the command in a script and let cron call only the script. Then no character gets interpreted:

Code
0 3 * * * /bin/bash /home/USER/scripts/backup.sh

We recommend this pattern for any cron job with more than one command. Debugging a script is infinitely easier than debugging a long line in the cPanel cron form.

Sixth trap: overlapping runs#

If a job takes longer than the cron interval, the next run starts while the previous one has not finished. For a job that writes to the database, this can create duplicate data.

Laravel's scheduler has withoutOverlapping for exactly this:

PHP
Schedule::command('stats:rollup')->hourly()->withoutOverlapping();

And if you have your own shell script, use a file lock:

Bash
#!/bin/bash
exec 9>/home/USER/locks/backup.lock
flock -n 9 || exit 0

# the rest of the work

The -n switch means "if the lock is held, do not wait, just exit". Exactly what you want for cron.

Environment variables in cron#

If your script needs an environment variable, cron does not have it. Three ways:

1. Define it on the cron line itself:

Code
0 3 * * * APP_ENV=production /opt/.../php /home/USER/app/artisan backup:run

2. Put it at the top of the crontab (it affects all lines):

Code
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=you@example.com

The second is useful: by defining MAILTO, the output of every cron job goes to your email. It is great during setup, but remove it afterwards or a per-minute cron job will fill your inbox.

3. Have the script read the environment file itself — the cleanest way, but watch out for the line-ending problem.

Cron troubleshooting checklist#

When a cron job does not run, go in this order:

  1. Does cron run at all? Add a simple line that only writes the date to a file and wait five minutes.
  2. Is the interpreter found? Test with env -i.
  3. Is the current directory right? Give the absolute path of artisan.
  4. Did you throw the output away? Temporarily redirect it to a file.
  5. Is there an error in Laravel's own log? Look at the files in storage/logs.

Take the first one seriously. We have seen several times that all the time went into debugging the command while the cron job had not even been registered.

The pattern we finally settled on#

After hitting this problem twice, we organised our cron jobs like this:

One single script in ~/scripts/cron.sh that holds everything inside it:

Bash
#!/bin/bash
set -euo pipefail

PHP=/opt/cpanel/ea-php84/root/usr/bin/php
APP=/home/USER/app
LOG=/home/USER/logs/cron.log

cd "$APP"

echo "[$(date -Is)] start" >> "$LOG"
"$PHP" artisan schedule:run >> "$LOG" 2>&1
echo "[$(date -Is)] end" >> "$LOG"

And in the cPanel cron form, just one line:

Code
* * * * * /bin/bash /home/USER/scripts/cron.sh

Its advantages:

  • The interpreter path is defined in one place. Upgrading the PHP version means changing one line, not hunting through the cron form.
  • The set -euo pipefail line makes the script stop at the first error instead of silently continuing.
  • Start and end times are logged, so you can tell whether cron ran at all — the first question of every debugging session.
  • No character gets interpreted in the cron form.

Just remember the script needs execute permission and Unix line endings. If you wrote it on Windows and uploaded it, be sure to see this note.

Bash
chmod +x ~/scripts/cron.sh

Log rotation, because nobody remembers it#

A per-minute cron job that writes two lines to a log produces about ninety thousand lines a month. If you leave it, it will eventually eat the account's disk space.

The simplest way with no special tool is a weekly cron job that truncates the file:

Code
0 4 * * 0 tail -n 5000 /home/USER/logs/cron.log > /home/USER/logs/cron.tmp && mv /home/USER/logs/cron.tmp /home/USER/logs/cron.log

The last five thousand lines are always enough for debugging, and the file never grows beyond a few megabytes.

The rule we keep for good: in cron jobs and any automated script, always use the absolute path of the binary — no aliases, no relying on the search path.