How to Run Scheduled Jobs in a PHP App Without a Queue
Write each job as a PHP CLI script, schedule it with cron, wrap the crontab line in flock -n so a slow run is never doubled, and make the job safe to run twice by recording what it has done in the database. Log every run and alert on failures. Add a queue only when work must start within seconds or spread across machines.
What cron is good at
Most small products need a handful of background tasks: back up the database, send the day-two onboarding email, retry stragglers from an external API, prune expired tokens. None of these needs Redis, a worker daemon or a framework scheduler. Cron starts a PHP script on a timetable, the script does a bounded amount of work and exits, and the operating system cleans up after it. Nothing stays resident to leak memory.
Cron is a poor fit for work a user is waiting for right now, such as an AI generation they just paid for. Do that in the request or with a provider's webhook, and keep cron as the backstop that catches anything the fast path missed.
Step 1: write the job as a CLI script
<?php // scripts/expire_trials.php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') { // never reachable over the web
exit(1);
}
require __DIR__ . '/../app/bootstrap.php';
$started = microtime(true);
$count = expire_trials(limit: 500); // bounded batch, safe to repeat
printf("[%s] expire_trials: %d updated in %.1fs\n",
gmdate('c'), $count, microtime(true) - $started);
Keep scripts outside the web root (a scripts/ folder next to public/), or refuse to run outside the CLI as above. Process a limited batch per run rather than "everything", so one huge backlog can't run for an hour.
Step 2: schedule it, and stop overlapping runs
Edit the crontab as the user your site's PHP runs as, so files the job creates have the right owner:
*/15 * * * * cd /var/www/app && flock -n /tmp/app-expire-trials.lock php scripts/expire_trials.php >> storage/logs/jobs.log 2>&1
flock -n takes a lock on the file and runs the command only if nobody else holds it. If last run is still going, the new one exits immediately instead of piling up. The lock belongs to the running process, so a crashed job doesn't leave a stale lock behind, which is the worry raised most often in the Stack Overflow threads on this. Give each job its own lock file.
If you can't change the crontab line, take the same lock inside PHP:
$lock = fopen(sys_get_temp_dir() . '/expire_trials.lock', 'c');
if (!flock($lock, LOCK_EX | LOCK_NB)) {
exit(0); // another run is still working
}
Two cron details cause most "works in the terminal, not in cron" reports: cron runs with a minimal PATH, so use the full path to php if needed; and a literal % in a crontab line means a newline unless you escape it as \%.
Step 3: make every job safe to run twice
Locks prevent overlap on one server, but jobs still get re-run: after a deploy, a timeout, or because you ran one by hand. Design each job so a second run does nothing harmful:
- Record what was done, keyed uniquely. A table such as
onboarding_steps(user_id, step_key)with a unique key means the day-two email is sent to each user once, however often the job runs. - Select only what still needs work.
WHERE status = 'pending' AND attempts < 5, not "all rows from today". - Claim before slow work if runs could overlap. Update a row to
processingin a short transaction, then do the slow part, then mark it done. - Never hold a database write lock across a network call, especially on SQLite, where one long write blocks every other writer. See SQLite in production.
Step 4: logs, alerts and a heartbeat
A cron job that fails quietly is worse than no job. Append each run's one-line summary to a log file and rotate it with logrotate. For anything important, also record the last successful run time in the database or ping an external heartbeat monitor at the end of the script. An alert fires when the ping stops arriving, which catches the failure you didn't think of, including cron itself not running.
Backups deserve this most. If the backup job stops, nothing visibly breaks until the day you need a restore.
When you actually need a queue
Move to a real queue when the numbers say so:
- Work must start within a second or two of being requested, at volume.
- You run more than one app server, and jobs must be shared out between them (
flockonly coordinates processes on one machine). - A single job type produces thousands of items a minute, or needs priorities and rate limits per customer.
Even then, a database table polled by a long-running worker under systemd is often enough before Redis or a hosted queue. Until then, a few crontab lines and a log file are easier to understand, test and recover.
Sources: Stack Overflow: How to prevent PHP script running more than once? · Stack Overflow: Using flock in crontab · PHP manual: flock · man7.org: flock(1) · man7.org: crontab(5)
How Simple Stack handles it
Simple Stack runs its background work exactly this way: plain PHP scripts in scripts/, each with a crontab line in DEPLOY.md, and no queue server to install. Add flock -n to those lines as shown above if a run could ever outlast its interval.
- scripts/backup_db.php: hourly or daily SQLite .backup snapshots with retention
- scripts/send_onboarding.php: the email drip, recorded per user and step so sent steps are not repeated
- A failed send is left unrecorded, so the next run retries it
- scripts/migrate_images_to_r2.php: a backstop for AI images the inline copy missed
- Each job is a no-op when its feature is switched off, so the crontab can stay the same
- scripts/doctor.php checks the install, and is handy as a scheduled health check
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.