Skip to main content

    Lesson 36 • Advanced

    Cron Jobs & Task Scheduling ⏰

    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.

    What You'll Learn in This Lesson

    • Read and write the five cron fields for any schedule
    • Install jobs with crontab -e and list them with crontab -l
    • Run a PHP CLI script the way cron does (php /path/script.php)
    • Capture all output and errors to a log file with >> ... 2>&1
    • Stop overlapping runs using flock and a lock file
    • Choose between crontab, systemd timers, and the Laravel scheduler

    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.

    The five cron fields, with real schedules
    # 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"
    Output
    # Cron does not "print" anything itself — it just launches the command
    # at the scheduled time. The five fields above decide WHEN.
    #
    # Quick reads of the lines above:
    #   * * * * *     -> every minute
    #   */5 * * * *   -> every 5 minutes
    #   0 * * * *     -> top of every hour
    #   0 9 * * 1-5   -> weekdays at 9 AM
    #   30 2 * * 0    -> Sundays at 2:30 AM
    #   0 0 1 * *     -> first day of every month, midnight
    This is real code — run it for free atonecompiler.com/bashor in your own editor.

    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).

    Editing the crontab and logging output
    # 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
    # MAILTO=ops@example.com
    
    # Other useful commands:
    crontab -l      # list the current user's jobs
    crontab -r      # remove ALL jobs for the user (careful!)
    Output
    # crontab -e opens vi/nano; saving installs the schedule immediately.
    # crontab -l then shows what is installed:
    
    30 2 * * * /usr/bin/php /var/www/app/backup.php >> /var/log/app/backup.log 2>&1
    */5 * * * * /usr/bin/flock -n /tmp/poll.lock /usr/bin/php /var/www/app/poll.php >> /var/log/app/poll.log 2>&1
    
    # After 02:30, tail the log to confirm it ran:
    #   $ tail -n 2 /var/log/app/backup.log
    #   [2026-06-16 02:30:01] Backup started
    #   [2026-06-16 02:30:04] Backup OK: 45MB written
    This is real code — run it for free atonecompiler.com/bashor in your own editor.

    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.

    backup.php — a script written for cron
    <?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);
    Output
    [2026-06-16 02:30:01] Backup started
    [2026-06-16 02:30:04] Backup OK: 45MB written
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    poll.php — refuse to run twice at once
    <?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);
    Output
    Lock acquired — doing the work...
    Work done.
    
    # If a second copy starts while the first still holds the lock, it prints:
    #   Another run is still going — skipping this one.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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: a weekday 6:45 AM job
    # 🎯 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
    Output
    45 6 * * 1-5 /usr/bin/php /var/www/app/report.php >> /var/log/app/report.log 2>&1
    Fill the three ___ with 45, 6, and 1-5. The result is one valid cron line.

    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.

    🎯 Your turn: add timestamped logging
    <?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
    Output
    [2026-06-16 03:00:00] Cleanup started
    [2026-06-16 03:00:00] Removed 12 files
    Set $stamp with date('Y-m-d H:i:s') and call logLine(...) twice. Two timestamped lines should print.

    Common Errors (and the fix)

    • "php: command not found" (or the script just never runs) — cron uses a bare PATH and a different working directory than your login shell. Use absolute paths for both PHP and the script: /usr/bin/php /var/www/app/job.php (find PHP's path with which php), and don't rely on relative paths or shell aliases.
    • The job runs but reads the wrong files / env vars are missing — cron doesn't load your ~/.bashrc or shell profile, so variables you set there are absent. Set them at the top of the crontab (PATH=..., APP_ENV=...) or load them inside the script, and cd to the project directory in the command.
    • Two copies clobber each other — a long run overlapped the next scheduled start. Wrap the command in flock -n /tmp/job.lock in the crontab, or call flock($handle, LOCK_EX | LOCK_NB) in PHP so the second run exits instead of piling up.
    • It fails silently and you can't tell why — you didn't capture output. Add >> /var/log/app/job.log 2>&1 to the cron line so both normal output and errors land in a file, then read it with tail -f.
    • "Call to undefined function" for a web-only feature — you assumed the web SAPI. From cron PHP runs as cli: there's no $_SESSION, no header() that a browser will see, and a different php.ini may apply. Write CLI scripts to read arguments and print to stdout, not to expect a request.

    Pro Tips

    • 💡 Test the command by hand first. Run the exact line (/usr/bin/php /var/www/app/job.php) in a terminal before trusting cron with it — most "cron bugs" are path or permission bugs that show up immediately when run directly.
    • 💡 Log a heartbeat, then alert on its absence. Have the job write a timestamp on success; if the log goes quiet, you know it stopped — far better than waiting for a user to notice.
    • 💡 Prefer the Laravel scheduler or systemd timers for anything non-trivial. They keep timing logic in version control and give you proper logs (journalctl) instead of a crontab nobody remembers editing.

    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.

    The Laravel scheduler — one cron line drives everything
    <?php
    // In Laravel you define schedules in code (routes/console.php or a Schedule
    // class), so timing lives in version control — not a crontab nobody can find.
    
    use Illuminate\Support\Facades\Schedule;
    
    // Run an Artisan command on weekdays at 06:45, without overlapping runs.
    Schedule::command('report:send')
        ->weekdaysAt('06:45')
        ->withoutOverlapping()           // built-in flock-style guard
        ->appendOutputTo('/var/log/app/report.log');
    
    // You only need ONE real cron line on the server for ALL of the above:
    //   * * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
    Laravel's withoutOverlapping() is the framework equivalent of flock; one cron line runs the whole schedule.

    📋 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

    Frequently Asked Questions

    Q: 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".

    Q: 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.

    Q: 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.

    Q: 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 MAILTO=you@example.com at the top of the crontab to be emailed any output.

    Q: 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.

    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.

    🎯 Mini-Challenge: write heartbeat.php
    <?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
    Acquire a non-blocking lock, print one timestamped "heartbeat OK" line, then release the lock. Picture the * * * * * cron line that would run it every minute.

    🎉 Lesson Complete!

    • ✅ A cron schedule is five fields — minute, hour, day-of-month, month, day-of-week — then the command
    • crontab -e installs jobs; crontab -l lists them
    • ✅ Run PHP from cron with absolute paths: /usr/bin/php /path/script.php
    • ✅ Capture output and errors with >> file.log 2>&1 — silent jobs are undebuggable
    • ✅ Stop overlaps with flock (in the cron line or via PHP's flock())
    • ✅ Cron's bare environment is the #1 gotcha — set PATH/env explicitly, and remember PHP runs as cli, not the web SAPI
    • ✅ For more, reach for systemd timers or the Laravel scheduler
    • Next lesson: Environment Management — store config and secrets securely with .env files

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service