PHP under cron is not the PHP your site runs on
The script runs when you execute it over SSH. It runs when you hit it in a browser. Scheduled, it produces a fatal error about a missing class, or nothing at all. Nobody has changed the code.
On a shared plan this has one of three causes, and all three come from the same fact: there is no single thing called "PHP" on that machine.
Find out which binaries exist, and which one cron gets
A typical managed host keeps one interpreter per supported version, plus a symlink that means "whatever the account is currently set to", plus whatever the base distribution installed for its own purposes. That last one is frequently several major versions behind and is the one first on the default PATH.
ls -d /opt/*/php* /opt/*/*/php* 2>/dev/null
command -v php php8 php8.2 php8.3
/usr/bin/php -v
/usr/local/bin/php -v
Then find out what cron sees, which is a different question and the only one that counts:
* * * * * { command -v php; php -v; } > $HOME/cron-php.txt 2>&1
Leave it for a minute, read the file, remove the line. On most shared accounts the version in that file is not the version the website runs, because the panel's PHP selector changes what the web server uses and does not change the login shell's PATH, and cron gets neither.
The fix is to stop naming the interpreter by its short name. Write the absolute path of the binary you tested:
0 3 * * * /opt/cpanel/ea-php83/root/usr/bin/php /home/client/public_html/artisan schedule:run
Ugly, specific, and it will not silently change version the next time somebody touches a dropdown. Which it otherwise will: a panel upgrade that moves the account from one PHP branch to the next updates the web side and leaves your crontab pointing at a binary that may or may not still exist. If the path disappears, cron reports nothing, because an unroutable error message is the default outcome.
The command line reads a different configuration file
Even with the correct binary, the settings differ. The command-line interpreter and the one behind the web server load their own configuration, and hosts do configure them differently on purpose.
Ask each side rather than assuming:
php --ini
php -r 'echo ini_get("memory_limit"), " ", ini_get("max_execution_time"), PHP_EOL;'
php -m | sort > /tmp/cli-modules.txt
And from a temporary file in the document root, deleted immediately afterwards, the same three questions answered by the web SAPI. Compare the two module lists. The differences that bite, in the order they show up on real sites:
An extension present on one side only. Imagick, intl, gd, sometimes the database driver itself. The script uses it, the web side has it, the command line does not, and the failure is a fatal error naming a class you did not know was optional.
Execution time. The command-line interpreter runs without the wall-clock limit that applies to web requests. That is normally a relief, and it is also why a runaway scheduled job on a shared plan can consume an account's process allowance for hours without anything stopping it.
Memory. Frequently more generous on the command line, occasionally less. Either way it is not the number in the panel, and the number in the panel is the one people quote when they say the script has 512 megabytes available.
Disabled functions. Some hosts disable exec, proc_open or putenv for web requests and not for the shell, or the reverse. A script that shells out works in one context and returns false in the other, usually without raising anything you would notice.
The panel's cron, and the URL it wants you to use
Most hosting panels offer two things: a form that writes a real crontab entry for your account, and a suggestion that you schedule wget or curl against a URL on your own site.
The form is fine. It writes to the same per-user crontab that crontab -l shows, so the two views agree, with one caveat worth knowing: the panel is the source of truth for some providers, and a change made by editing the file directly gets overwritten the next time the panel writes. Use whichever one the provider treats as authoritative, and only that one.
The URL suggestion is where the trouble is, and it is worth four sentences because it is the single most common shape for scheduled work on shared hosting.
The web timeout applies. A URL-triggered job runs inside a normal web request, so it inherits the request time limit that the command line does not have. A long import dies partway through with a gateway error and no clean rollback, which from the outside is a 504 with nothing in your own logs.
Anyone can run it. The endpoint is on the public internet. Search engines find it, scanners find it, and a competitor who guesses the path can run your client's nightly job forty times in a minute. If the URL must exist, it needs a long random token in the path and a check that refuses everything else, and it should still be rate limited.
The output goes into the response. Errors are rendered as a web page that nobody reads, or swallowed by the error handler, rather than arriving on stderr where a redirect could capture them.
It appears in the access log as traffic. Which corrupts analytics if it is not filtered, and makes the site look busier than it is.
Where you have shell access at all, run the interpreter directly. Where you genuinely do not, and some plans still work that way, at least give the endpoint its own token, set an expected status code, and log the run somewhere the endpoint itself does not control.
The shape that survives on a shared plan
One entry, absolute paths everywhere, output redirected, and no reliance on the environment.
MAILTO=""
PATH=/usr/local/bin:/usr/bin:/bin
# example.com, minute derived from the domain
45 * * * * cd /home/client/public_html && /opt/cpanel/ea-php83/root/usr/bin/php artisan schedule:run >> /home/client/logs/cron.log 2>&1
The cd is not decoration. Scripts that resolve includes relative to the current directory, which describes a great deal of older PHP, behave differently depending on where they were launched from, and cron launches from the home directory.
What that line still cannot tell you is whether it ran. A shared account migrated to a new server, a plan changed, a panel that rewrote the crontab: all three remove the entry and none of them writes anything to your log, because the log is only written by a job that starts. Absence has no signature, which is why the check has to live somewhere other than the machine being checked.