Scheduled tasks in web apps: crons that don't surprise you
Every web app grows scheduled work. Nightly reports, expiring trials, invoice reminders, the cleanup job that deletes stale exports. These tasks run unattended at hours nobody is awake, and that is the problem: a cron that breaks does not interrupt anyone's afternoon. It breaks quietly for weeks until someone asks why the Monday report stopped arriving in August.
Make the task safe to run twice
The single most useful property of a scheduled job is idempotency. If the task can run twice without damage, half of the classic cron failures become non-events: the scheduler restarts, the server clock drifts, a teammate's machine runs the same command during testing.
Where idempotency isn't possible, the task must not overlap itself. A nightly sync that takes 40 minutes on a bad night will eventually still be running 24 hours later when its next run starts, and two copies writing the same records is a worse outage than the delay. Laravel's scheduler has withoutOverlapping() for a lock around a single server, and onOneServer() when the fleet needs to agree that one machine runs the job while the rest skip it. The general shape is the same everywhere: take a lock before work, hold it until the work ends, decide up front what happens when the lock is taken.
Fail loudly, because success is silent
A cron that succeeds says nothing. So the thing to monitor is not exceptions but heartbeats: has this task run successfully within its expected interval? An exception handler only tells you about runs that crashed. It stays silent about a task that exits cleanly while doing no work, or a crontab entry a deploy script overwrote six weeks ago.
The cheap version: after each run, write a row ("report.generated", timestamp) and have the monitoring check assert the latest row is younger than 25 hours. The alert fires on the first missed run, not the first customer complaint about a missing report. Keep task output in a log too, with the run duration. A cleanup job creeping from 2 minutes to 90 minutes over three months is a story worth seeing before it becomes a timeout.
Keep the schedule thin, the logic elsewhere
The scheduler entry should be a dumb trigger that dispatches a queued job, and the job should hold the logic. This buys two things. Retries: if the report generation fails halfway, it retries through the queue machinery instead of waiting for tomorrow's cron. And testability: you can run the job's class directly with real arguments, no faking of the clock needed, while the schedule itself stays boring enough to read in one screen.
One trap worth naming: timezones. A schedule written as "17:00" means different things before and after a DST change, and servers running UTC make it mean neither of the two things the person who wrote it intended. Pin the timezone on each scheduled entry explicitly, and remember that a "daily digest at 09:00" for users in several zones is two or three distinct schedules, not one.
Test scheduled work like code
Locally, run the scheduler against the clock: Laravel's schedule:test executes a named task on demand without waiting for the minute to come around. Then assert the side effects, not the exit code: the email queued, the rows expired, the export file exists. Most scheduled-task bugs are business-logic bugs wearing a crontab, and they show up the first time the task runs against data it has never seen, such as a leap day or a user who signed up at 23:58.