Why Some Work Should Never Happen During a Request
Sending a daily report email, cleaning up expired sessions, recalculating statistics, charging recurring subscriptions — none of this should happen while a user is waiting for a page to load. Cron jobs and task schedulers exist to run this kind of work on a timer, independent of any specific user request, and understanding how to do it reliably is a core backend skill that tutorials often skip past quickly.
The Foundation: Cron Itself
On a Linux server, cron is the system service that runs commands on a schedule defined in a crontab file. A typical PHP application registers one single cron entry that runs every minute:
# crontab -e
* * * * * cd /var/www/myapp && php artisan schedule:run >> /dev/null 2>&1Rather than defining dozens of separate cron entries (one per task, each with its own schedule syntax to get right), modern PHP frameworks centralize scheduling in application code, with cron itself doing nothing more than triggering the framework's scheduler every minute. Laravel's scheduler is the most common example:
protected function schedule(Schedule $schedule)
{
$schedule->command('reports:daily')->dailyAt('06:00');
$schedule->command('sessions:cleanup')->hourly();
$schedule->job(new RecalculateTrendingProducts)->everyFifteenMinutes();
}What Happens When a Scheduled Task Fails
This is the part beginners almost universally get wrong: a scheduled task that throws an exception or times out needs to be noticed by a human, not silently skipped until someone happens to realize a report never arrived. At minimum, wrap scheduled tasks so failures are logged with full context and trigger an alert:
$schedule->command('reports:daily')
->dailyAt('06:00')
->onFailure(function () {
Log::error('Daily report generation failed');
Notification::route('slack', config('alerts.slack_webhook'))->notify(new ScheduledTaskFailed('reports:daily'));
});Overlap Protection
If a task that processes a large dataset takes longer than its scheduled interval, the next run can start before the previous one finishes — two instances of the same job running concurrently, potentially double-processing data or fighting over the same database rows. Schedulers provide a withoutOverlapping()-style guard specifically for this:
$schedule->command('invoices:process')->everyFiveMinutes()->withoutOverlapping();Running on a Single Server in a Multi-Server Setup
Once an application runs on more than one server behind a load balancer, a naive cron setup runs the same scheduled task on every server simultaneously — sending the same email five times, charging a subscription five times. The fix is to designate one server as responsible for scheduled tasks (via a flag, a lock in a shared cache like Redis, or framework-level support for this), so the work runs exactly once regardless of how many web servers are running.
Queued Jobs Versus Scheduled Tasks
It is worth distinguishing two related but different tools: a scheduled task runs on a timer regardless of any user action; a queued job runs once, triggered by something that just happened (a user placed an order, so queue a job to send a confirmation email). Both move work out of the request-response cycle, but for different reasons — one for recurring maintenance, one for deferring slow work triggered by a specific event.
Monitoring: The Step Most Setups Skip
A cron job that silently stops running — because a server was rebuilt and the crontab never got reinstalled, or a deploy script overwrote it — can go unnoticed for weeks if nothing is watching for it. A simple, effective pattern: have each critical scheduled task ping a dead-man's-switch monitoring service (or write a heartbeat timestamp to a database row) on every successful run, and alert if that heartbeat goes silent for longer than expected.
Closing Thought
Cron and task scheduling look trivial — "run this command every day at 6am" — until the realities of failure handling, overlap protection, and multi-server deployments are factored in. The actual engineering work is not the scheduling syntax; it is making sure a failed or skipped run is something your team finds out about before a customer does.
Need reliable background processing built into your application? We can set it up properly.
Timezone Pitfalls in Scheduled Tasks
A scheduler configured with dailyAt('06:00') runs at 6am in whatever timezone the application (or the server) is configured to use — and a mismatch between the server's system timezone, PHP's configured timezone, and the timezone your business actually operates in is a frequent, confusing source of "why did this run at the wrong time" bugs, especially around daylight saving transitions in regions that observe them. Always set the timezone explicitly in application config rather than relying on whatever the server happens to default to, and be explicit about which timezone a schedule is meant to run in if your team or user base spans more than one.
Idempotency in Scheduled Tasks
If a scheduled "charge subscriptions due today" job runs, partially completes, and then the server is restarted or the job crashes, what happens when it runs again on the next scheduled tick? Without care, customers can be charged twice. Scheduled tasks that have real-world side effects need the same idempotency thinking applied to API endpoints: track which specific subscriptions have already been processed for today's run, and skip them on a retry, rather than assuming a job either fully succeeds or fully fails with no in-between state.
Local Development: Running the Scheduler Without Real Cron
Most developers do not have a real cron daemon running on their local development machine, which means scheduled tasks are easy to forget about until they break in production. Running the scheduler manually in a loop during development (a one-line command most frameworks provide) keeps the team honest about what runs on a schedule and surfaces bugs before deployment rather than after.
# Laravel example: runs the scheduler loop locally without real cron
php artisan schedule:workLogging: What a Healthy Scheduled-Task Log Looks Like
Every scheduled task run should leave a trace: when it started, when it finished, whether it succeeded, and a count of whatever it processed ("processed 142 invoices, 0 failures"). Without this, diagnosing "did last night's job actually run, and did it do what it was supposed to" requires guesswork instead of a quick log lookup — a difference that matters enormously during an actual incident at 2am.
Queue Workers Versus Cron-Triggered Commands
It is worth being precise about which background-processing tool fits which job. A queue worker is a long-running process that continuously pulls jobs off a queue as they arrive — ideal for "do this specific thing now, in the background, because a user just triggered it." A cron-scheduled command runs once, on a fixed timer, regardless of whether anything triggered it — ideal for "do this recurring maintenance task regardless of user activity." Many real systems need both, and conflating them (running genuinely event-triggered work purely on a timer, polling for new work every minute instead of reacting immediately) adds needless latency to what should be instant background processing.
What a Production-Grade Scheduled Task Checklist Looks Like
Before considering a scheduled task production-ready, it should satisfy: a defined behavior on failure (retry? alert? both?), protection against overlapping runs if the task can occasionally run long, a single source of truth for which server runs it in a multi-server deployment, structured logging of start/end/outcome, and an owner — a specific person or team who would actually notice and respond if it silently stopped running for a week. That last point sounds organizational rather than technical, but in practice it is the difference between a scheduled task that gets fixed within hours of breaking and one that quietly fails for months until a customer complains.
An Example: Why "Send Reminder Emails Daily" Is More Subtle Than It Sounds
Consider a task that emails users a reminder about an upcoming subscription renewal, three days before it is due. The naive version queries "subscriptions renewing in exactly 3 days" and emails all of them, once a day. The subtlety: what happens if the job does not run on a given day — a deploy, a server issue, a bug? Without care, those users never get a reminder at all, since "exactly 3 days from now" never matches again once that day has passed. A more robust version checks a range ("renewing in 1 to 3 days") and tracks which subscriptions have already received a reminder, so a missed run on one day is caught by the next day's run instead of silently skipping affected customers altogether.
Scheduled Tasks and Database Load
A scheduled task that runs a heavy aggregation query at the top of every hour, at the exact same minute every server in a fleet wakes up to check the schedule, can produce a visible spike in database load on a predictable cadence — sometimes mistaken for a separate performance bug because it correlates with the clock rather than with traffic. Staggering heavy scheduled tasks slightly (a small random jitter, or deliberately offsetting different tasks to different minutes) smooths this out and avoids self-inflicted, recurring load spikes.
Case Study: A Reminder Email That Went Out Twice
A subscription business had a daily scheduled task emailing renewal reminders, deployed across two web servers behind a load balancer with no coordination between them about which one should run scheduled tasks. For months, only one server happened to have cron configured correctly (a deployment script bug), so nobody noticed the problem. After a server rebuild fixed the script, both servers began running the scheduler, and every customer started receiving two identical reminder emails daily — a minor annoyance that nonetheless generated a wave of support tickets and a public complaint on social media before anyone traced it back to the cause. The fix was a Redis-backed lock acquired at the start of the scheduled run, released at the end, so a second server attempting the same task within the same window simply skipped it. The deeper lesson: a setup that appears to work fine for months under one set of conditions (one server effectively running scheduled tasks) can fail loudly the moment that hidden assumption changes, which is exactly why explicit single-server coordination should be built in from the start of any multi-server deployment, not added reactively after an incident.
A Decision Table for Choosing a Scheduling Approach
For a single-server application: plain cron triggering a framework scheduler is sufficient, no special locking needed. For a multi-server application: add a distributed lock (Redis-based or via your framework's built-in support) so tasks run exactly once regardless of server count. For tasks triggered by specific events rather than a timer: use a queue, not a scheduler, since queued jobs react immediately rather than waiting for the next scheduled tick. For tasks with strict timing requirements (must run within seconds of a specific moment): consider whether a scheduler's typical minute-level granularity is precise enough, or whether the requirement actually calls for an event-driven approach instead.
Common Mistakes Worth Naming Directly
Hardcoding absolute file paths in a crontab entry that breaks the moment the application is deployed to a new path. Forgetting that cron runs with a minimal environment, missing environment variables the application normally relies on, causing a task that works fine when run manually to fail silently under cron. Not redirecting output anywhere, so a script that crashes with a fatal error leaves no trace at all of what happened. Each of these is avoidable with a small amount of deliberate setup, and each has caused real production incidents at companies that skipped that setup.
Step-by-Step: Migrating From a Pile of Cron Entries to a Centralized Scheduler
Many older PHP applications accumulate scheduled tasks as a growing, unmanaged list of separate crontab lines added by different developers over time, each with its own ad-hoc logging (or none at all). Migrating to a centralized, code-based scheduler is worth doing deliberately rather than all at once. Step one: inventory every existing crontab entry across every server, since these are easy to lose track of and some may be forgotten duplicates or genuinely dead tasks nobody remembers the purpose of. Step two: for each one, write an equivalent scheduled command in the framework's scheduler, preserving the exact same timing, and run both side by side temporarily to confirm equivalent behavior before removing the old entry. Step three: add the failure handling, overlap protection, and logging that the old ad-hoc crontab entries almost certainly lacked. Step four: once every task has been migrated and verified, remove the old crontab entries entirely, leaving only the single line that triggers the framework scheduler. This staged approach avoids the risk of breaking business-critical scheduled work (billing, reporting) during the migration itself.
A Comparison Table: Scheduling Tools at a Glance
Raw cron entries calling individual scripts directly: simplest possible setup, but no centralized visibility, no built-in failure handling, and schedule changes require editing crontab on every server. Framework-native schedulers (Laravel's Schedule facade and similar): centralizes all scheduled tasks as version-controlled application code, supports failure callbacks and overlap protection out of the box, requires only one crontab entry per server. Dedicated job-scheduling services (managed cloud schedulers, queue-based delayed dispatch): offload scheduling reliability to a managed service, useful when an application runs across ephemeral or auto-scaling infrastructure where "which server runs the scheduler" is a harder question to answer than on a small fixed set of servers.
Security Considerations for Scheduled Tasks
A scheduled task often runs with elevated privileges compared to a normal web request, since there is no authenticated user session to scope its permissions — it typically acts with whatever broad access the application process itself has. This makes scheduled tasks a meaningful target if an attacker can somehow influence what they do: a task that reads configuration or input from a location a lower-privileged part of the system can write to (a shared file, a database row editable through a less-trusted interface) can become an unintended privilege escalation path. Treat any input a scheduled task consumes with the same suspicion as user input arriving through a web form, even though it feels like "internal" data.
Observability: Making Scheduled Tasks Visible to the Whole Team
Beyond basic logging, mature teams expose scheduled-task health on the same dashboards used for general application monitoring — last successful run time, average duration, failure count over the past week — so that a scheduled task quietly failing is as visible as a web endpoint returning errors, rather than living in a separate, easily-ignored log file nobody checks unless something is already known to be wrong.
Final Checklist Before Shipping a New Scheduled Task
Does it have explicit failure handling that alerts a real person, not just a log line nobody reads? Is it protected against overlapping runs if it can occasionally take longer than its interval? In a multi-server deployment, is there a coordination mechanism ensuring it runs exactly once? Is its behavior idempotent, so a retry after a partial failure does not double-process anything with real-world side effects? Is its timezone behavior explicit and tested around daylight saving transitions if relevant to your user base? Does it produce a clear, queryable trace of every run, including what it processed and whether it succeeded?
Testing Scheduled Tasks Without Waiting for the Clock
A scheduled task should be testable by directly invoking its underlying logic, decoupled from the actual timing mechanism — if the only way to test "does the reminder email task work correctly" is to wait for an actual cron tick, the team will simply stop testing it regularly. Structuring the command itself as a thin wrapper around a testable service class lets tests invoke the real logic instantly and assert on its behavior, while the scheduler configuration separately controls only when that logic runs in production.
public function testReminderTaskOnlyEmailsSubscriptionsRenewingSoon()
{
$soon = Subscription::factory()->create(['renews_at' => now()->addDays(2)]);
$later = Subscription::factory()->create(['renews_at' => now()->addDays(20)]);
(new SendRenewalReminders())->handle();
Mail::assertSentTo($soon->user, RenewalReminder::class);
Mail::assertNotSentTo($later->user, RenewalReminder::class);
}Closing Thought
A scheduled task that nobody is watching is a liability waiting for the right combination of circumstances to turn into an incident — a missed renewal charge, a report that silently stopped generating, a cleanup job that quietly broke months ago. The technical mechanics of scheduling are genuinely simple; the discipline of failure handling, coordination across servers, and visibility into whether scheduled work actually ran as expected is where the real engineering effort belongs, and where most of the value of getting this right actually lives.
How This Plays Out Differently in a Small Team Versus a Large One
A two-person startup can often get away with a single shared crontab entry, manual log checking, and tribal knowledge about which tasks matter most — not because the underlying risks described in this guide disappear, but because the small number of people involved can absorb the coordination overhead informally. As a team and codebase grow, that informal coordination breaks down precisely at the moments it matters most — a new engineer who does not know a particular task exists, an on-call rotation where the person responding to an alert was not the one who originally wrote the task. Investing in the explicit failure handling, locking, and observability covered throughout this guide pays off increasingly as team size grows, even if it feels like unnecessary ceremony on a very small team.
What to Do When You Inherit a Codebase With Undocumented Cron Entries
Taking over a server with a crontab full of cryptic entries, no comments, and no obvious owner is a common and uncomfortable situation. Resist the urge to delete anything immediately; instead, log what each entry actually does for a week or two without changing it, build an understanding of what depends on it, and only then migrate or remove entries with confidence. A surprising number of these mystery cron jobs turn out to be either critical (a backup, a billing run) or genuinely dead code left over from a feature removed years ago with nobody remembering to clean up its scheduled task — and the cost of guessing wrong in either direction is high enough to justify the patience of observing first.
A Final Word on Treating Scheduled Tasks as First-Class Code
The single biggest mindset shift that improves scheduled-task reliability across a codebase is treating them with the same seriousness as user-facing endpoints — code reviewed the same way, tested the same way, monitored the same way — rather than as an afterthought bolted on once the "real" feature is done. Scheduled tasks frequently handle some of the most business-critical logic in an application (billing, data integrity maintenance, compliance reporting) precisely because they run unattended, which makes the case for treating them carefully stronger, not weaker, than for ordinary request-handling code.