Cron Jobs & Task Scheduling

Reviewed & published by Brayan K

By the end of this lesson you'll be able to read and write cron expressions, schedule a PHP CLI script to run automatically, log its output, and stop two runs from colliding — the toolkit behind every nightly backup and email digest.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Cron Syntax: The Five Fields

A cron job is a command your server runs automatically on a schedule. That schedule is written as five space-separated fields followed by the command: minute hour day-of-month month day-of-week command. An asterisk (*) means "every value", */5 means "every 5th", a dash like 1-5 is a range, and a comma like 1,15 is a list. Learn to read these six lines and you can express almost any schedule.

# A cron line has FIVE time fields, then the command to run.
# Read them left to right:
#
#   ┌───────────── minute        (0 - 59)
#   │ ┌─────────── hour          (0 - 23)
#   │ │ ┌───────── day of month  (1 - 31)
#   │ │ │ ┌─────── month         (1 - 12)
#   │ │ │ │ ┌───── day of week    (0 - 6, Sunday = 0)
#   │ │ │ │ │
#   * * * * *  command to run

# * means "every".  */5 means "every 5th".  1-5 is a range.  1,15 is a list.

* * * * *      echo "runs every minute"
*/5 * * * *    echo "runs every 5 minutes"
0 * * * *      echo "runs at minute 0 of every hour"
0 9 * * 1-5    echo "runs at 09:00, Monday to Friday"
30 2 * * 0     echo "runs at 02:30 every Sunday"
0 0 1 * *      echo "runs at midnight on the 1st of each month"

Cron itself prints nothing — it just launches the command at the right moment. The fields only decide when. A common gotcha: the day-of-month and day-of-week fields are OR-ed together, so 0 0 13 * 5 runs on the 13th and on every Friday, not only Friday the 13th.

2️⃣ Installing a Job with crontab -e

Each user has their own crontab (cron table). You edit it with crontab -e, which opens an editor; the moment you save, the schedule is live. The command in a cron line is run by a bare shell, so you give the full path to PHP and the full path to your script: /usr/bin/php /var/www/app/backup.php. Cron throws away your script's output unless you capture it — so always redirect it to a log file with >> file.log 2>&1 (append stdout, and send errors to the same place).

# Open YOUR personal crontab in an editor:
crontab -e

# ...then add lines like these. ALWAYS use absolute paths in cron —
# cron runs with a bare environment, so "php" or "./script.php" may not resolve.

# Run a PHP CLI script every night at 02:30, logging everything to a file.
# >> appends stdout to the log;  2>&1 sends stderr (errors) to the same place.
30 2 * * * /usr/bin/php /var/www/app/backup.php >> /var/log/app/backup.log 2>&1

# Every 5 minutes, but never let two copies overlap (see flock below).
*/5 * * * * /usr/bin/flock -n /tmp/poll.lock /usr/bin/php /var/www/app/poll.php >> /var/log/app/poll.log 2>&1

# Set PATH and MAILTO at the top of the crontab if your scripts need them:
# PATH=/usr/local/bin:/usr/bin:/bin
# [email protected]

# Other useful commands:
crontab -l      # list the current user's jobs
crontab -r      # remove ALL jobs for the user (careful!)

3️⃣ The PHP CLI Script Cron Runs

The script cron launches is ordinary PHP, but run from the command line rather than a browser. That mode is called the CLI SAPI (Server API), and php_sapi_name() returns "cli" there. There is no web page, so everything you echo goes to stdout — which the cron line redirects into your log file. Finish with a clear exit code: exit(0) for success, anything else for failure.

<?php
// backup.php — a PHP script meant to be run from the COMMAND LINE by cron,
// not requested through a browser. Run it yourself with:  php backup.php
//
// php_sapi_name() tells you HOW PHP was started. From cron it is "cli".
if (php_sapi_name() !== 'cli') {
    exit("Run this from the command line, not the web.\n");
}

// A timestamped logger so every line in the log file is traceable.
function logLine(string $msg): void {
    $stamp = date('Y-m-d H:i:s');     // e.g. 2026-06-16 02:30:01
    echo "[{$stamp}] {$msg}\n";       // cron redirects this echo into the log file
}

logLine('Backup started');

// Do the real work. Pretend we copied the database to a file.
$bytes  = 47185920;                    // 45 MB, as bytes
$sizeMb = round($bytes / 1024 / 1024); // -> 45

logLine("Backup OK: {$sizeMb}MB written");

// Exit code 0 = success. Cron records a non-zero code as a failed run.
exit(0);

Because each line is timestamped with date('Y-m-d H:i:s'), the log tells you not just that the job ran but when — the first thing you'll want when something breaks at 2 AM.

4️⃣ Preventing Overlap with flock

What happens if a job scheduled */5 * * * * sometimes takes seven minutes? Cron starts the next copy on top of the first, and now two runs fight over the same data. A file lock stops this: the first run grabs an exclusive lock; any later run that can't grab it simply exits. You can do this in the cron line with the flock command (shown in section 2) or inside PHP with the flock() function below.

<?php
// poll.php — runs every 5 minutes from cron. But what if one run takes
// 7 minutes? Without protection, a second copy starts on top of the first.
//
// A file LOCK fixes this: the first run grabs the lock; later runs that
// can't grab it simply exit. (You can also do this in the cron line with
// the 'flock' command — both approaches are shown in this lesson.)

$lockFile = '/tmp/poll.lock';
$handle   = fopen($lockFile, 'c');                 // open (create if missing)

// LOCK_EX = exclusive lock, LOCK_NB = non-blocking (don't wait — fail fast).
if (!flock($handle, LOCK_EX | LOCK_NB)) {
    echo "Another run is still going — skipping this one.\n";
    exit(0);                                        // not an error, just busy
}

echo "Lock acquired — doing the work...\n";
// ... real work happens here ...
echo "Work done.\n";

flock($handle, LOCK_UN);                            // release the lock
fclose($handle);

5️⃣ Your Turn: Write a Cron Line

Now you write the schedule. The line below is almost complete — fill in each ___ using the field order and the 👉 hints, then check it against the Output panel.

# 🎯 YOUR TURN — write ONE cron line.
# Goal: run  /usr/bin/php /var/www/app/report.php  every weekday at 6:45 AM,
# appending all output (and errors) to /var/log/app/report.log
#
# Fill in each ___ . Remember the field order:
#   minute hour day-of-month month day-of-week  command

# 1) minute  -> 45
# 2) hour    -> 6  (24-hour clock)
# 3) dom     -> *  (any day of month)
# 4) month   -> *  (any month)
# 5) dow     -> 1-5 (Monday through Friday)

___ ___ * * ___ /usr/bin/php /var/www/app/report.php >> /var/log/app/report.log 2>&1

# 👉 replace the three ___ with  45  ,  6  , and  1-5
# ✅ Expected line:
#    45 6 * * 1-5 /usr/bin/php /var/www/app/report.php >> /var/log/app/report.log 2>&1

One more — this time the PHP script. Cron will redirect its output to a log, so give every line a timestamp. Fill in the blanks and run it.

<?php
// 🎯 YOUR TURN — make this cron script log WITH timestamps.
// Fill in each ___ , then run it with:  php cleanup.php

function logLine(string $msg): void {
    $stamp = ___;                 // 👉 use date('Y-m-d H:i:s') for the timestamp
    echo "[{$stamp}] {$msg}\n";   // cron will redirect this into the log file
}

___("Cleanup started");           // 👉 call your logLine() function
$deleted = 12;                    // pretend we removed 12 stale files
___("Removed {$deleted} files");  // 👉 log the result the same way

// ✅ Expected output (timestamp will differ):
//    [2026-06-16 03:00:00] Cleanup started
//    [2026-06-16 03:00:00] Removed 12 files

Common Errors (and the fix)

Pro Tips

6️⃣ Alternatives: systemd Timers & the Laravel Scheduler

Plain crontab is everywhere, but two alternatives solve its weak spots. systemd timers pair a .timer unit (when) with a .service unit (what), giving you centralised logging via journalctl -u myjob, dependencies, and easy enable/disable. The Laravel scheduler flips the model: you define all schedules in PHP and add a single cron line that runs php artisan schedule:run every minute.

📋 Quick Reference — Cron Fields & Commands

Field / TokenRange / ExampleMeaning
1st fieldminute (0-59)Minute of the hour
2nd fieldhour (0-23)Hour, 24-hour clock
3rd fieldday of month (1-31)Day number in the month
4th fieldmonth (1-12)Month of the year
5th fieldday of week (0-6)0 = Sunday … 6 = Saturday
** * * * *Every value (here: every minute)
*/n a-b a,b*/5 1-5 1,15Every nth · range · list
crontab -e / -lcrontab -eEdit / list your cron jobs
>> log 2>&1>> job.log 2>&1Append output + errors to a log
flockflock -n /tmp/j.lock cmdPrevent overlapping runs

Mini-Challenge: A Self-Protecting Heartbeat

No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This combines the lock and the timestamped log into one real cron-ready script.

<?php
// 🎯 MINI-CHALLENGE: a self-protecting heartbeat script.
// No code is filled in — work from the steps, then run it with:  php heartbeat.php
//
// 1. Open a lock file at /tmp/heartbeat.lock with fopen(..., 'c').
// 2. Try flock($handle, LOCK_EX | LOCK_NB). If it FAILS, echo a
//    "already running" message and exit(0).
// 3. If it succeeds, echo a timestamped line:  "[Y-m-d H:i:s] heartbeat OK"
//    (use date('Y-m-d H:i:s')).
// 4. Release the lock with flock($handle, LOCK_UN) and fclose($handle).
//
// Then imagine the crontab line that runs it every minute and logs output:
//    * * * * * /usr/bin/php /path/heartbeat.php >> /var/log/heartbeat.log 2>&1
//
// ✅ Expected output (timestamp will differ):
//    [2026-06-16 12:00:00] heartbeat OK

// your code here

🎉 Lesson Complete!

Practice quiz

What is the correct order of the five cron fields?

  • hour, minute, day, month, year
  • day-of-week, month, day, hour, minute
  • minute, hour, day-of-month, month, day-of-week
  • second, minute, hour, day, month

Answer: minute, hour, day-of-month, month, day-of-week. Read left to right: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), day-of-week (0-6), then the command.

What does the cron expression '0 9 * * 1-5' mean?

  • 09:00, Monday to Friday
  • Every 9 minutes on weekends
  • 9 PM on the 1st to 5th of the month
  • Midnight every 9 days

Answer: 09:00, Monday to Friday. Minute 0, hour 9, any day-of-month, any month, day-of-week 1-5 (Mon-Fri) — so 9 AM on weekdays.

What does the token '*/5' mean in the minute field?

  • Exactly minute 5
  • Minutes 1 through 5
  • Five times total
  • Every 5th minute

Answer: Every 5th minute. * means 'every', */5 means 'every 5th', a-b is a range, and a,b is a list.

Why must you use absolute paths in a crontab command?

  • Cron only accepts uppercase paths
  • Cron runs with a bare environment, so 'php' or './script.php' may not resolve
  • Relative paths are a syntax error
  • Absolute paths run faster

Answer: Cron runs with a bare environment, so 'php' or './script.php' may not resolve. Cron uses a minimal PATH and a different working directory, so use /usr/bin/php /var/www/app/script.php — full paths to both.

How do you capture both normal output and errors from a cron job to a log file?

  • >> file.log 2>&1
  • | file.log
  • > file.log only
  • 2>file.log only

Answer: >> file.log 2>&1. >> appends stdout to the log and 2>&1 sends stderr to the same place. Without this, a failing job is silent and undebuggable.

Which command opens your personal crontab in an editor?

  • crontab -l
  • crontab -r
  • crontab -e
  • cron --edit

Answer: crontab -e. crontab -e edits (and installs on save), crontab -l lists, and crontab -r removes ALL of the user's jobs.

How do you stop two copies of a cron job from running at the same time?

  • Schedule it less often and hope
  • Wrap it in flock with a lock file (or call flock() in PHP)
  • Run it as root
  • Add MAILTO to the crontab

Answer: Wrap it in flock with a lock file (or call flock() in PHP). flock -n /tmp/job.lock (or PHP's flock($handle, LOCK_EX | LOCK_NB)) makes a second run exit instead of overlapping.

In a PHP CLI script, what does php_sapi_name() return when run from cron?

  • 'fpm'
  • 'cron'
  • 'apache'
  • 'cli'

Answer: 'cli'. From the command line (and cron) PHP runs as the CLI SAPI, so php_sapi_name() returns 'cli'.

Which exit code signals a successful cron run?

  • exit(1)
  • exit(0)
  • exit(200)
  • exit(-1)

Answer: exit(0). exit(0) means success; any non-zero code is recorded by cron as a failed run.

Which is a modern alternative to plain crontab on a single server?

  • An infinite PHP while-loop
  • A browser refresh tag
  • systemd timers (a .timer plus a .service unit)
  • A Composer script

Answer: systemd timers (a .timer plus a .service unit). systemd timers give logging via journalctl, dependencies, and easy enable/disable; the Laravel scheduler keeps timing logic in PHP with one cron line.

Continue this course

Frequently asked questions

What do the five fields in a cron expression mean?

Left to right they are: minute (0-59), hour (0-23), day of month (1-31), month (1-12), and day of week (0-6, where 0 is Sunday). After those five fields comes the command to run. An asterisk (*) means "every", */5 means "every 5th", 1-5 is a range, and 1,15 is a list. So 0 9 * * 1-5 means "at 09:00, Monday to Friday".

Why does my PHP script work in the terminal but fail from cron?

Cron runs with a minimal environment: a bare PATH, a different working directory, and usually no shell profile. Commands like php or relative paths like ./script.php often aren't found. The fix is to use absolute paths everywhere (/usr/bin/php /var/www/app/script.php), set PATH at the top of the crontab if needed, and never assume cron's environment matches your login shell's.

How do I stop a cron job from running twice at the same time?

Wrap the command in flock, which uses a lock file so a second copy refuses to start while the first is still running. In the crontab: */5 * * * * /usr/bin/flock -n /tmp/job.lock /usr/bin/php /var/www/app/job.php. The -n means "non-blocking" — if the lock is held, this run exits immediately instead of piling up. You can also call PHP's flock() function inside the script itself.

How do I see the output or errors from a cron job?

Cron does not show output on screen, so redirect it to a log file: append >> /var/log/app/job.log 2>&1 to the command. The >> appends stdout, and 2>&1 sends stderr (errors) to the same file. Without this, a failing job is silent and almost impossible to debug. You can also set [email protected] at the top of the crontab to be emailed any output.

Are there modern alternatives to plain crontab?

Yes. systemd timers (a .timer plus a .service unit) give you logging via journalctl, dependencies, and easy enable/disable — good on a single server. In Laravel, you define schedules in PHP with the scheduler ($schedule->command('report:send')->weekdaysAt('06:45')) and add just one cron line that runs php artisan schedule:run every minute, so all your timing logic lives in version-controlled code.