Cron & queue workers
The one cron entry that runs everything — the full schedule table, queue configuration for shared and VPS hosting, and the cron-based worker pattern.
Almost everything time-driven in Zodiac Tech CRM — reminders, WhatsApp appointment sweeps, Meta syncs, billing lifecycle, backups, e-mail sequences — rides on one cron entry hitting Laravel's scheduler every minute. On production installs the queue should be database, drained by the same cron (or a supervised worker on VPS).
Who this is for
- Super-admin / hosting admin — wiring the cron line.
- Developer — choosing queue strategy per host.
- Support — "e-mails/reminders aren't sending" almost always ends here.
Prerequisites: the app is installed and reachable.
Step 1 — Add the cron entry
Standard (Terminal/SSH available or cPanel Cron Jobs):
* * * * * cd /home/YOURUSER/crm-app && php artisan schedule:run >> /dev/null 2>&1
Shared hosting without a usable PHP binary path: use cPanel's shown PHP binary:
* * * * * /usr/local/bin/ea-php84 /home/YOURUSER/crm-app/artisan schedule:run >> /dev/null 2>&1
No SSH and no artisan cron available — the shipped fallback runner:
* * * * * php /home/YOURUSER/public_html/crm/cron.php
cron.php drains queued jobs (up to 30 per run, within a ~55-second budget) across all named queues, then runs schedule:run — a complete replacement for both the worker and the scheduler. It requires storage/installed.lock, and if you open it in a browser it refuses to work unless you set a CRON_SECRET first.
Expected result: within two minutes, storage/logs/laravel.log starts showing scheduled task activity, and queued e-mails/jobs stop accumulating in the jobs table.
Step 2 — Configure the queue
QUEUE_CONNECTION |
Behaviour | When to use |
|---|---|---|
sync |
Jobs run inside the web request | Local dev / last resort (slower pages; no cron needed) |
database (recommended) |
Jobs stored in MySQL, drained by a worker | Shared hosting and VPS |
redis |
Fastest; needs Redis + a worker | VPS/dedicated with Redis available |
The shipped .env.example already sets QUEUE_CONNECTION=database. Jobs are partitioned into named queues — default, webhooks, automations, integrations, notifications, exports, ai-sdr, mail — so heavy work (exports, AI) cannot delay time-sensitive work (webhooks, mail).
Step 3 — Drain the queue
Shared hosting (recommended): add a second cron entry that runs a finite worker every minute:
* * * * * cd /home/YOURUSER/crm-app && php artisan queue:work --queue=default,webhooks,automations,integrations,notifications,exports,ai-sdr,mail --stop-when-empty --max-time=55 >> /dev/null 2>&1
--stop-when-empty exits when there is nothing to do; --max-time=55 guarantees the run ends before the next minute's cron fires. Combined with the scheduler entry from Step 1, this needs no long-running process. (The cron.php fallback already does this for you.)
VPS / dedicated (supervised worker): run a persistent worker with a process supervisor, and list the named queues explicitly:
php artisan queue:work --queue=default,webhooks,automations,integrations,notifications,exports,ai-sdr,mail --tries=3 --timeout=120
Workers that don't list queues queue:work defaults to the default queue only. A worker started without --queue=… will silently ignore webhooks, mail, exports and AI jobs.
After every deploy, restart workers so they pick up new code:
php artisan queue:restart
The full schedule
Everything below is registered in routes/console.php. Every task runs with withoutOverlapping(10) (a stuck run never blocks the slot for a day) and logs a structured warning — schedule task failed: <name> — if it throws.
| Time / frequency | Task | What it does |
|---|---|---|
| Every 5 min | CheckImapMailboxes |
Pulls new mail from configured IMAP mailboxes into the inbox |
| Every 5 min | CheckAppointmentApproaching |
Fires appointment_approaching WhatsApp automations inside the configured lead time |
| Every 5 min | CheckFollowUpDue |
Fires follow_up_due automations for overdue tasks |
| Every 5 min | tasks:send-reminders |
Notifies assignees about due follow-ups |
| Every 15 min | ProcessEmailSequences |
Sends the next e-mail in active sequences |
| Every 15 min | ProcessAiSdrEnrollments |
One AI decision/action per due enrollment |
| Every 15 min | leadhub:sync-calendars |
Two-way calendar sync (Google/Outlook) |
| Every 30 min | leadhub:meta-ops-scan |
Meta ads needs-attention scan (no delivery, CPL spikes, dead connections) |
| Every 30 min | ReconcileProviderStatus |
Re-checks payment provider status as a webhook backstop |
| Every 30 min | SyncAppliedChangeRequests |
Applies approved commercial change requests |
| Hourly | CheckNoActivityAutomations |
Fires automations for leads with no activity |
| Hourly | notifications:digest |
Batches notification digests |
| Hourly | reports:deliver |
Sends scheduled reports |
| Hourly | filters:send-alerts |
Saved-filter alerts |
| Hourly | CheckPaymentFailures |
Detects failed payments and starts the failure lifecycle |
| Every 6 h | leadhub:meta-health |
Pings stored Meta page tokens so "connected but expired" surfaces early |
| Daily 02:00 | leadhub:backup |
Nightly backup — only if the toggle is on (see Backups & restore) |
| Daily 03:00 | leadhub:purge-audit-logs |
Retention: 180-day audit logs, 30-day login attempts, 90-day billing events |
| Daily 03:15 | leadhub:purge-recordings |
30-day call-recording retention |
| Daily 03:30 | leadhub:capture-benchmarks |
Captures industry benchmarks for reporting |
| Daily 04:00 | leadhub:execute-erasure |
GDPR Article 17 deletions whose cool-off elapsed |
| Daily 04:30 | leadhub:sync-meta-ads --days=30 |
Pulls campaigns, ads, insights |
| Daily 06:00 | leadhub:subscription-lifecycle |
Subscription state transitions |
| Daily 06:30 | CreateRenewalInvoice |
Renewal invoices for due agreements |
| Daily 07:00 | CheckUpcomingRenewals |
Reminds clients of upcoming renewals |
| Daily 07:00 | CheckDateFieldAutomations |
Birthday/anniversary greetings |
| Daily 07:15 | ApplyGracePeriod |
Starts grace for failed renewals |
| Daily 07:15 | leadhub:meta-ops-brief |
Daily ads brief for the agency team |
| Daily 07:30 | ApplyRestriction |
Escalates overdue accounts to RESTRICTED |
| Daily 08:15 | DetectStuckOnboarding |
Nudges clients stuck in guided onboarding |
Pruning happens in-task Retention windows are enforced by the purge tasks above; override them with LH_AUDIT_RETENTION_DAYS, LH_LOGIN_ATTEMPT_RETENTION_DAYS and LH_BILLING_EVENT_RETENTION_DAYS — see Environment variables.
Failed jobs
With database queues, failures land in the failed_jobs table:
php artisan queue:failed # list
php artisan queue:retry all # retry everything
php artisan queue:failed-forget all # discard
Webhook deliveries and automations are designed to be retry-safe (idempotent), so queue:retry all is normally safe. Investigate recurring failures via storage/logs/laravel.log.
Common mistakes
- Adding the schedule entry but not the worker drain (with
database) — scheduled dispatches work but queued jobs never run. - Two
queue:workprocesses on the same queue without--stop-when-emptysupervision — duplicated processing on non-idempotent jobs. - Forgetting that cron runs as a different user/php.ini — test the command manually and check
storage/logs. - Using
syncin production "because it works" — page loads get slow and a mail timeout blocks the request. - Not running
queue:restartafter deploys (supervised workers keep old code).
Troubleshooting
| Symptom | Check |
|---|---|
| Nothing scheduled ever runs | Cron entry syntax; PHP binary path; cd into the app directory; log file |
| Jobs table grows forever | Worker not running (or queue:work missing --queue= for named queues) |
| Reminders not sending / one queue backed up | Schedule a check: is tasks:send-reminders running? Is mail queue processed? |
| "schedule task failed: X" warnings | The named task threw — inspect laravel.log around that timestamp |
| Duplicate WhatsApp sends | Overlap guard bypassed by a manual worker run — stop stray workers |
Technical details
- Schedule definition:
routes/console.php(all tasks;withoutOverlapping(10)+onFailurelogging). - Shared-host runner: root
cron.php(30 jobs/55 s acrossdefault,webhooks,automations,integrations,notifications,exports,ai-sdr,mail, thenschedule:run). - Nightly backup gate:
App\Settings\GeneralSettings->auto_nightly_backuporconfig('leadhub.backups.auto_nightly'). - Backup command:
leadhub:backup(--prune=DAYSoptional).
Related guides
Was this page helpful?
Thanks — noted. ✦