Skip to content
Cron Cookbook

How to run a PHP script as a cron job

To run a PHP script on a schedule, add a line to your crontab with crontab -e: the five schedule fields, then the full path to php and to the script, as in */5 * * * * /usr/bin/php /var/www/app/cron.php. Cron runs the PHP command line, not your web server, so the script gets a different environment from the one you test in the browser.

Five things that trip up PHP cron jobs

  1. Cron needs the full path to php

    Cron's PATH is short. Find the binary with command -v phpand write it out, along with the script's full path.
  2. The CLI reads its own php.ini

    Settings and extensions you see in phpinfo() on the web may differ from what the command line loads. Check with php --ini.
  3. The working directory is your home

    Relative paths in require or fopen break. Build them from __DIR__.
  4. Errors vanish unless you log them

    Redirect the output to a file, or cron mails it to an inbox nobody reads.
  5. A slow run can overlap the next

    On the command line max_execution_time defaults to 0, so nothing stops a stuck script. Wrap it in flock.

Add the job

First run the script by hand from the command line, exactly as cron will: /usr/bin/php /var/www/app/cron/send-emails.php. If it fails there, it will fail in cron. Then open your crontab with crontab -e, add the line and save. The job runs as the user whose crontab it is, so pick the user that owns the files the script writes, often www-data: sudo crontab -u www-data -e.

On shared hosting with cPanel or Plesk, there's no shell: the control panel's Cron Jobs page asks for the schedule and the command separately. The command is the same, with the PHP path your host lists.

Use the full path to php

Cron doesn't read your shell profile, and its PATHis short (Debian's cron uses /usr/bin:/bin), so a php installed in /usr/local/bin or by a version manager isn't found. Run command -v php in your terminal and copy the result into the crontab. A server with several PHP versions has a binary for each, such as /usr/bin/php8.3; name the version your app needs. A % in the command line, as in date +%F, must be written \%, or cron cuts the command there.

The command line has its own php.ini

PHP-FPM and Apache's module load one configuration; the php command loads another. On Debian and Ubuntu they live in /etc/php/8.3/fpm and /etc/php/8.3/cli. Run php --ini to see which file the command line reads, and php -m to list its extensions. A missing extension or a different memory_limit is the usual reason a script works in the browser and fails from cron. On the command line max_execution_time defaults to 0, no limit.

Don't rely on the working directory

Cron starts each job in the user's home directory, not the script's folder, so require 'config.php' looks for ~/config.php. Build paths from __DIR__, the directory of the file itself, or put cd /var/www/app && before the command.

$_SERVER['HTTP_HOST'], $_GETand sessions don't exist on the command line. Pass options as arguments instead (they arrive in $argv), and check PHP_SAPIso a cron script can't be run from a browser:

cron/send-emails.php
<?php
// Refuse to run from a browser
if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit;
}

// Paths relative to this file, not to cron's working directory
require __DIR__ . '/../vendor/autoload.php';
$config = require __DIR__ . '/../config.php';

echo date('c'), " sending queued emails\n";

Log the output

Whatever the script prints, warnings and fatal errors included, goes to cron, which mails it to the crontab's owner, and on most servers that mail goes nowhere. Append >> /var/log/app/job.log 2>&1 to keep both output and errors in a file. To confirm cron started the job at all, read its log: journalctl -u cron on Debian and Ubuntu, journalctl -u crond on Fedora and RHEL.

Stop runs from overlapping

Cron starts the next run on time even if the last one is still going. A job every 5 minutes that once takes 7 ends up running twice at the same time, sending emails twice or locking the same rows. Put /usr/bin/flock -n /tmp/job.lock in front of the command: it holds a lock while the script runs, and -n makes the next run exit at once if the lock is taken. Inside PHP, flock() on a file you keep open does the same.

Laravel, Symfony and WordPress

Laravel keeps its schedule in code and needs one cron entry that runs schedule:run every minute; the framework decides which tasks are due:

crontab
* * * * * cd /var/www/app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1

Symfony's Scheduler component runs as a long-lived worker (messenger:consume) rather than from cron, though many Symfony apps still call console commands from crontab the same way.

WordPress's built-in WP-Cron isn't cron: it runs due tasks when someone visits the site, so a quiet site runs them late. Turn it off in wp-config.php and run the due events from real cron with WP-CLI:

wp-config.php and crontab
# wp-config.php
define( 'DISABLE_WP_CRON', true );

# crontab: run due WordPress events every 5 minutes
*/5 * * * * /usr/local/bin/wp --path=/var/www/html cron event run --due-now >> /var/log/wp-cron.log 2>&1

Run it through a URL instead

If you can't run php from cron at all, cron (or an outside cron service) can request a page with curl. The script then runs in the web server with the web php.ini and its time limits, and anyone who finds the URL can run it, so require a secret:

crontab
*/15 * * * * /usr/bin/curl -fsS "https://example.com/cron.php?key=LONG_RANDOM_SECRET" > /dev/null

Common schedules for PHP jobs

ExpressionRunsTypical job
* * * * *Every minute.Laravel and Symfony schedulers, queue checks
*/5 * * * *Every 5 minutes.Sending queued email, WordPress events
*/15 * * * *Every 15 minutes.Syncing with an API, clearing a cache
0 * * * *Every hour, on the hour.Imports, sitemaps, cleanup
30 2 * * *At 02:30 every day.Reports and backups, when traffic is low
0 6 * * 1At 06:00 on Mondays.A weekly summary email

Times are the server's

Cron reads the schedule in the server's time zone, which on most cloud servers is UTC. PHP's date.timezone setting changes what date() prints, not when cron starts the job.

Need the five fields themselves? See the cron expression syntax guide, or paste an expression into the cron explainer.