cronuru
Guide

PHP Cron Scheduling — A Complete Guide

PHP's scheduling story splits between framework-managed scheduling (Laravel's `Schedule` facade, Symfony's Scheduler) and the older pattern of system crontab invoking PHP CLI scripts. Both work; the framework approaches are better for ergonomics and observability, system cron is better for low-overhead one-liners.

Updated

The landscape

ToolWhat it doesBest for
Laravel SchedulerBuilt-in scheduler for Laravel appsLaravel apps (clear choice)
Symfony SchedulerBuilt-in scheduler for Symfony 6.3+Symfony apps
dragonmantank/cron-expressionCron expression parsing libraryCustom schedulers, validation, next-run display
System crontab + php CLITraditional cron calling PHP scriptsStandalone PHP, simple needs
Workerman, Swoole timersLong-running PHP process schedulersReal-time PHP apps

For Laravel and Symfony users, the choice is obvious — use the built-in. For other PHP frameworks (Yii, CakePHP, plain PHP) or standalone scripts, system cron calling php /path/to/script.php is the standard pattern.

Laravel Scheduler

Laravel ships with a fluent scheduling DSL. As of Laravel 11 (2024), schedules live in routes/console.php using the Schedule facade — the old app/Console/Kernel.php schedule() method no longer exists in new apps:

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:daily')
    ->dailyAt('09:00')
    ->timezone('America/New_York')
    ->emailOutputOnFailure('alerts@example.com');

Schedule::command('cache:prune')
    ->everyFiveMinutes()
    ->withoutOverlapping();

Schedule::call(function () {
    // closure-based task
})->hourly();

Schedule::job(new GenerateReportJob)
    ->cron('0 9 * * MON-FRI');

(On Laravel 10 and earlier, the same method chains go inside the schedule(Schedule $schedule) method in app/Console/Kernel.php instead.)

This generates schedule entries, but Laravel doesn’t run them itself — you add one crontab entry that ticks the scheduler every minute:

* * * * * cd /path/to/laravel && php artisan schedule:run >> /dev/null 2>&1

schedule:run reads your defined schedule, figures out which tasks should fire in the current minute, and runs them. So you only need to edit Laravel code, never the crontab.

Key methods worth knowing:

  • ->cron('* * * * *') — raw cron expression
  • ->everyMinute(), ->everyFiveMinutes(), ->hourly(), ->dailyAt('13:30'), ->weeklyOn(1, '9:00') — fluent shortcuts
  • ->withoutOverlapping() — cache-based lock; skips if previous fire still running
  • ->onOneServer() — for multi-server deploys, runs on exactly one server (requires a shared cache like Redis)
  • ->runInBackground() — don’t wait for the task to finish before scheduling the next one
  • ->emailOutputTo(...), ->emailOutputOnFailure(...) — pipe stdout to email
  • ->before(fn() => ...), ->after(fn() => ...) — hooks
  • ->between('9:00', '17:00'), ->weekdays(), ->weekends() — time/day constraints

For local development, php artisan schedule:work runs the scheduler in the foreground without needing crontab — useful for testing.

Symfony Scheduler

Symfony Scheduler (introduced in Symfony 6.3) is built on top of Symfony Messenger. Define a ScheduleProvider:

use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;

#[AsSchedule('default')]
class MainSchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())
            ->add(
                RecurringMessage::cron('0 9 * * MON-FRI', new SendDailyDigestMessage()),
                RecurringMessage::every('5 minutes', new RefreshCacheMessage()),
            );
    }
}

Then create message handlers and run the consumer:

php bin/console messenger:consume scheduler_default

The consumer is a long-running process — deploy it like any other queue worker (supervisor, systemd, k8s).

Differences from Laravel’s approach:

  • Long-running consumer, not a crontab tick. Symfony Scheduler runs continuously and dispatches messages at the right times. No need for a separate crontab entry.
  • Built on Messenger. Scheduled tasks dispatch into the same message bus your async jobs use. Easier to scale, monitor, and observe.
  • More trigger types. Cron, periodic intervals, and callback-based triggers all supported via the same API.

dragonmantank/cron-expression

The standard PHP library for parsing cron expressions. Used internally by Laravel Scheduler and many other tools.

composer require dragonmantank/cron-expression
use Cron\CronExpression;

$cron = new CronExpression('*/5 * * * *');

// Next run from now
$next = $cron->getNextRunDate();
echo $next->format('c');

// Validate a user-submitted expression
if (CronExpression::isValidExpression($userInput)) {
    // safe to use
}

// Get the next 10 runs. The second arg is a skip count (0 = the next run),
// so start at 0 — starting at 1 would skip the first upcoming run.
for ($i = 0; $i < 10; $i++) {
    echo $cron->getNextRunDate('now', $i)->format('c') . "\n";
}

Use cron-expression when you need to:

  • Parse user-submitted cron expressions for validation
  • Display “next run” times in an admin UI
  • Compute frequencies or schedule durations
  • Build a custom scheduling layer

The library accepts standard 5-field Unix cron — see the Unix cron dialect reference. For Quartz, EventBridge, or other dialects, you’ll need to convert first.

System cron + PHP CLI

For non-framework PHP or standalone scripts, the traditional pattern is fine:

0 0 * * * /usr/bin/php /var/www/scripts/nightly-cleanup.php >> /var/log/cleanup.log 2>&1
*/5 * * * * /usr/bin/php /var/www/scripts/poll-api.php

Tips for PHP CLI cron scripts:

  • Use absolute paths. /usr/bin/php and /var/www/scripts/..., not bare names.
  • Set the PATH at the top of the crontab so anything the script shells out to is findable.
  • Redirect stderr to stdout, both to a logfile. >> log 2>&1 is the standard idiom.
  • Use flock to prevent overlapping runs:
    */5 * * * * /usr/bin/flock -n /tmp/poll.lock /usr/bin/php /var/www/poll.php
  • Check exit codes. Cron sends an email (if MAILTO is set) when the command exits non-zero. Make sure your script returns 0 on success and non-zero on failure.

Most importantly: write a small wrapper script that handles common PHP CLI concerns (chdir, autoload, error reporting), then have your scheduled tasks require it. Avoids duplicating boilerplate across every cron script.

Choosing the right approach

  • Laravel app? → Laravel Scheduler. No real alternative makes sense.
  • Symfony 6.3+ app? → Symfony Scheduler.
  • Plain PHP or older framework? → System cron + PHP CLI scripts.
  • Need to parse cron expressions for validation/display? → dragonmantank/cron-expression.
  • Need SLA-grade reliability? → Move scheduling out of PHP. Use Kubernetes CronJob or AWS EventBridge Scheduler to invoke a PHP HTTP endpoint on a schedule.

The “external scheduler invokes a PHP endpoint” pattern is the cleanest production-grade option — your PHP app stays stateless, the scheduler handles failure modes, and the scheduling decision is decoupled from your application code.

Frequently asked questions

Which PHP scheduling tool should I use?
Laravel apps: Laravel's built-in Scheduler (artisan schedule:run hooked to system cron). Symfony apps: Symfony Scheduler component. Other PHP frameworks or standalone CLI scripts: system crontab calling `php script.php`. For parsing cron expressions in PHP without running anything, use dragonmantank/cron-expression.
Does Laravel Scheduler actually replace cron?
Not entirely. Laravel Scheduler still relies on one system cron entry that runs `php artisan schedule:run` every minute. Laravel itself decides which scheduled tasks to fire based on that minute tick. So you still need a working crontab — Laravel just lets you define the schedule in a fluent PHP DSL instead of editing the crontab directly.
How do I prevent overlapping runs in Laravel Scheduler?
Chain `->withoutOverlapping()` to your scheduled task. Laravel acquires a cache-based lock for the duration of the task; if a previous run is still going, the new fire is skipped. Specify a max-lock-duration in minutes if your task can hang.
What's Symfony Scheduler vs Laravel Scheduler?
Conceptually similar — both define schedules in framework code and rely on a worker process to fire them. Symfony Scheduler is newer (Symfony 6.3+), built on top of Symfony Messenger. It supports more trigger types (cron, periodic, callback) and integrates with Messenger's queue infrastructure. Laravel's is older, more established, and requires no extra infrastructure beyond a system cron entry.
Can I run sub-minute schedules in PHP?
Not via system cron — minimum is 1 minute. Laravel and Symfony both can technically poll faster if you run the scheduler as a long-running process (`php artisan schedule:work`, symfony `messenger:consume`) but for true sub-minute precision use a different scheduling model — a queue worker with periodic poll, or move scheduling out of PHP entirely.