You wrote a PHP script that regenerates the nightly sitemap, warms a Redis cache hourly, or exports a product feed at 4am. It works when you type php script.php, then silently fails under cron. The reason is almost always the same: the php in your shell is not the same binary cron sees, and the php.ini cron loads is not the one your phpinfo() reports.
PHP has four credible ways to schedule work on PHP 8.3 / 8.4: classic crontab + php-cli, a long-lived PHP daemon (Workerman, ReactPHP, Amphp, Swoole), a framework scheduler (Laravel, Symfony), or a small HTTP endpoint hit by external HTTP cron like Crontap.
Path 1: crontab + php-cli
The classic shape: a script on disk, one line in crontab -e, php from CLI.
# crontab -e
15 4 * * * /usr/bin/php /var/www/app/bin/export-feed.php >> /var/log/feed.log 2>&1Three things make this brittle in PHP specifically:
- Use the absolute path to the PHP binary.
which phpin your shell may print/usr/local/bin/php(a Homebrew orupdate-alternativessymlink) while cron only sees/usr/bin/php. Different binary, different extensions, differentphp.ini. php-cli.iniis notphp-fpm.ini. On Debian they live at/etc/php/8.3/cli/php.iniand/etc/php/8.3/fpm/php.iniwith different defaults formemory_limit,max_execution_time,date.timezone, and loaded extensions. This is the single most common reason a script that "works in the browser" dies under cron. Runenv -i /usr/bin/php -i | grep "Loaded Configuration"to see what cron loads.- OPcache is off in CLI by default. Composer's autoloader hits disk on every invocation. Set
opcache.enable_cli=1for tight loops.
Path 2: Long-running PHP daemons
Workerman, ReactPHP, Amphp, and Swoole let you run PHP as a persistent worker with an in-process scheduler loop.
Be honest: PHP idiomatically is not a daemon language. Memory leaks in long-running PHP are a known footgun, and most teams pick these libraries for queue workers (Redis, RabbitMQ, Beanstalkd), not scheduling. If you already run a Workerman or Swoole worker, adding a scheduled tick is fine. If you do not, this is the wrong tool to introduce just for cron.
Path 3: Framework schedulers and managed hosts
Most modern PHP teams never touch raw crontab. They use a framework scheduler that resolves to one cron entry on the host.
- Laravel Scheduler. Define jobs in
app/Console/Kernel.php, then one cron line:* * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1. See Laravel cron jobs. - Symfony Messenger + Scheduler. Symfony 6.3+ ships a Scheduler component wired to Messenger handlers.
- cPanel cron. The UI most shared hosts ship, 1-minute floor. See cPanel cron jobs.
- Managed PHP hosts (Cloudways, ServerPilot, Forge) wrap
crontabwith a friendlier UI.
Path 4: External HTTP cron hitting PHP-FPM
Skip cron on the box entirely. Add a small /cron/run endpoint that requires a bearer header and calls your work. The shape is the same in Slim, Mezzio, vanilla PHP, or a Laravel/Symfony controller.
// public/cron.php
$secret = getenv('CRON_SECRET');
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($auth !== "Bearer {$secret}") {
http_response_code(401);
exit;
}
require __DIR__ . '/../src/jobs/export_feed.php';
http_response_code(204);Point a Crontap schedule at POST https://yourapp.com/cron/run, attach Authorization: Bearer <secret>, pick a cron expression and IANA timezone. Pro is $3.25/mo annual flat for unlimited HTTP schedules at minute cadence on a 1-minute floor.
Schedule your PHP jobs from outside the box. Free forever tier with one schedule. Try Crontap →
Common PHP cron pitfalls
memory_limitcli vs fpm. A job that fits in 256M under FPM may hit the 128M CLI default.max_execution_time = 0in CLI. Unlimited. A loop forever piles up runs until the box OOMs. Wrap withtimeout 600.date.timezonedifferences. CLI may default toUTCwhile FPM hasEurope/Berlin. Set it in both inis.extension_dirmismatch. CLI may be missingintl,gd,imagick, or PDO drivers that work in FPM. Runphp -mfrom a cron shell to verify.$_SERVERand$_ENVnot populated under cron. Usegetenv()andvlucas/phpdotenv.- OPcache disabled in CLI. Composer autoloader perf takes a hit on every run.
FAQ
Why does my PHP script work manually but fail in cron?
Almost always one of three things: different php binary, different php.ini (CLI vs FPM), or missing env vars. Run env -i /usr/bin/php -i to see exactly what cron sees.
Should I use Laravel Scheduler or external cron for Laravel?
Scheduler is great inside one app. External HTTP cron wins when you want retries, Slack alerts, or scheduling that survives a redeploy. Many teams use both: scheduler for in-app ticks, Crontap for the heartbeat that wakes schedule:run. See Laravel cron jobs.
Can I schedule WordPress cron jobs this way?
Yes. Set define('DISABLE_WP_CRON', true); in wp-config.php, then hit wp-cron.php from Crontap. See Replace WordPress wp-cron and Cron jobs for WordPress.
What about long-running PHP jobs?
If a job exceeds your HTTP timeout, split it. The endpoint enqueues work into a queue (Redis, Beanstalkd, database) and a daemon worker (Path 2) processes it. Crontap fires the enqueue every minute; the work happens out-of-band.
Related on Crontap
- Laravel cron jobs. The framework-specific deep dive.
- cPanel cron jobs. Shared-hosting PHP setups.
- Cron troubleshooting. The hub when cron misbehaves.
- Cron job monitoring. Alert when PHP crons miss or fail.
- Replace WordPress wp-cron. The PHP-specific WordPress fix.
