Queues and scheduled work
What runs in the background, how to keep the worker alive, and what cron runs every day.
This guide covers the two things that happen outside a web request: jobs that wait in a queue for a worker, and tasks the scheduler runs on a clock. You need it when a job is not running, when a deploy did not take effect in the background, or when you add work of your own.
The queue connection
config/queue.php reads QUEUE_CONNECTION, default database: jobs are rows in the jobs table, which the kit's migrations create, and failed jobs go to failed_jobs (driver database-uuids). No extra service is needed. The connections and their variables:
| Connection | Variables | Notes |
|---|---|---|
database |
DB_QUEUE_CONNECTION (default: the main database), DB_QUEUE_TABLE (jobs), DB_QUEUE (default), DB_QUEUE_RETRY_AFTER (10) |
The default. retry_after is ten seconds: a job that runs longer than that without finishing is handed to another worker as if it had crashed. Raise it above your longest job, and keep it above the worker's --timeout |
redis |
REDIS_QUEUE_CONNECTION (default), REDIS_QUEUE (default), REDIS_QUEUE_RETRY_AFTER (90) |
Faster and lighter on the database. Needs the Redis connection in config/database.php |
sqs |
SQS_PREFIX, SQS_QUEUE, SQS_SUFFIX, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION |
Amazon SQS |
beanstalkd |
BEANSTALKD_QUEUE_HOST, BEANSTALKD_QUEUE, BEANSTALKD_QUEUE_RETRY_AFTER |
Beanstalkd |
sync |
none | Runs the job inside the request that dispatched it. Fine for a test script; in production it blocks the person who triggered the work |
QUEUE_FAILED_DRIVER (default database-uuids) chooses where failed jobs are kept. Job batches use the job_batches table.
What the kit queues
| Job or class | Dispatched when | What the worker does |
|---|---|---|
App\Jobs\SendAnnouncementEmailsJob |
App\Observers\AnnouncementObserver sees an announcement saved with status Published, on creation or on the change to that status |
Walks every user who is not blocked, one hundred at a time, and sends AnnouncementNotification to each: email when the announcement has "send email" and the user accepts email, web push when the user has a subscribed browser. The announcement's own delivery, including the push requests, happens here and not in the admin's request |
WeblaborMx\BillingCore\Jobs\MigrateSubscriptionsToPlan |
An administrator confirms the "Migrate plan users" modal, opened from "Migrate subscribers" on a price; dispatched after the transaction commits | Moves the selected subscriptions, or all of them, to the destination price through the plan migration service |
TicketOpenedNotification, TicketRepliedNotification, TicketAnsweredNotification |
A ticket is opened, a user replies or reopens it, or the team answers it | Sends the notice to each recipient: notification centre, email and push, with the real-time event and the communications log. The person who acted gets the response at once |
Anything you write that implements ShouldQueue |
You call dispatch() or notify() |
Notifications and mailables that implement ShouldQueue are serialised and sent by the worker |
What is not queued, so you know where to look when it is slow: notifications built on App\Notifications\Notification are sent during the request, database row, email and push included, because the base class uses Queueable without ShouldQueue; the three ticket notices above are the exception. The email verification code is sent synchronously with Mail::mailer(...)->send(), and the SMS code goes out synchronously too. Add implements ShouldQueue to a notification of your own when it fans out to many people; the announcement job is the pattern for doing that in bulk.
To create a queued job use the project stub, which already implements ShouldQueue:
php artisan make:job Orders/ArchiveOldOrders
Run the worker locally
composer dev starts four processes in one terminal: php artisan serve, php artisan queue:listen --tries=1, php artisan pail --timeout=0 (the log tail) and npm run dev. A job you dispatch runs a moment later and its output appears in the same terminal. queue:listen reloads the code on every job, so you do not restart it after editing a job class; that is why it is used here and not in production.
If you would rather not run a worker, set QUEUE_CONNECTION=sync in your local .env. Everything runs inline and exceptions surface in the request that caused them.
Run the worker in production
The worker is a long-lived process:
php artisan queue:work --sleep=3 --tries=3 --max-time=3600
queue:work loads the application once and keeps it in memory, which is what makes it fast and what makes it stale after a deploy. Keep it alive with Supervisor; the complete program block is in /help/deploy-your-project. --sleep=3 is the pause when the queue is empty, --tries=3 the attempts before a job is marked failed, and --max-time=3600 the lifetime after which the worker exits so Supervisor restarts it.
Add --queue=high,default to work several named queues in priority order, and --timeout=60 to kill a single job that runs longer than a minute (keep it below the connection's retry_after).
Restart it on every deploy
php artisan queue:restart
The command writes a timestamp into the cache; each worker checks it after every job and exits when it is newer than its own start. Supervisor then starts a new worker, which loads the new code. Two consequences: the cache store has to be one the worker and the web process share (database or redis, never array), and a job that is running when you call it finishes first. Without this command the worker keeps executing the previous release: a notification whose text you changed keeps arriving with the old text until the worker happens to die.
Failed jobs
A job that throws on every one of its --tries attempts lands in failed_jobs with its exception. The exception is also reported through the normal channels: the log, and Slack when SLACK_BOT_TOKEN is set.
php artisan queue:failed # list them, with id, connection, queue and date
php artisan queue:retry 9f1c-... # push one back onto the queue by its id
php artisan queue:retry all # push all of them back
php artisan queue:forget 9f1c-... # delete one without retrying
php artisan queue:flush # delete them all
php artisan queue:prune-failed --hours=168 # delete the ones older than a week
Retry after fixing the cause and restarting the worker; retrying a job into the old code fails it again.
Scheduled tasks
The scheduler runs everything registered with Schedule:: in routes/console.php and in the service providers of the packages. Among the tasks that ship with the kit:
| Command | When | What it does |
|---|---|---|
php artisan stats:compute-daily |
Every day at 00:00 | Counts the users whose last_logged_at is yesterday and stores the number in the stats table under the key daily_logged_users, one row per day. The admin dashboard reads that table. Declared in routes/console.php |
php artisan billing:sync-exchange-rates |
Every day at 12:00, only when FEATURE_PLANS_ENABLED or FEATURE_ADDONS_ENABLED is on |
Finds the payment dates of paid subscriptions charged in a currency other than primary_currency in config/pricing.php (default mxn), and fetches and stores the exchange rate for each date that has none. The provider is Frankfurter (exchange_rates.endpoint, https://api.frankfurter.app, timeout exchange_rates.timeout, ten seconds). --date=2026-09-01 syncs one date. Declared in the Billing Core service provider; the currency setup is in /help/currencies-intervals-and-exchange-rates |
php artisan billing:return-to-free-plan |
Every day at 03:30, only when FEATURE_PLANS_ENABLED is on |
Puts every account whose subscription ended and that has no plan back on the free plan of its country, as the end of the subscription already does on its own; an account it cannot move keeps the reason and is tried again the next day. Declared in the Billing Core service provider; see /help/when-your-account-is-left-without-a-plan |
php artisan media:purge-unrelated |
Every hour | Deletes the files uploaded more than 24 hours ago that were never saved into a record, such as an upload left behind when a form was abandoned, and frees their space. Declared in routes/console.php |
php artisan tickets:close-unattended |
Every hour, only when FEATURE_TICKETS_ENABLED is on |
Closes the answered tickets nobody replied to after FEATURE_TICKETS_AUTO_CLOSE_DAYS, and warns their owners once FEATURE_TICKETS_AUTO_CLOSE_WARNING_DAYS have passed. Declared in routes/console.php; see /help/support-tickets |
php artisan tickets:due-summary |
Monday to Friday at 08:00, only when FEATURE_TICKETS_ENABLED is on |
Sends the support team a summary of the tickets close to or past their deadline, when there is at least one. Declared in routes/console.php |
php artisan support-chats:sweep |
Every minute, only while the live chat is on | Closes the chats idle for FEATURE_TICKETS_CHAT_IDLE_MINUTES and keeps the queue moving. Open support screens run the same sweep, so without the scheduler a chat is only closed while someone has one open. Declared in routes/console.php; see /help/support-chat |
Times are in the application timezone, UTC in config/app.php. php artisan schedule:list prints the table with the next run of each.
Nothing runs unless cron calls the scheduler every minute:
* * * * * cd /var/www/your-project && php artisan schedule:run >> /dev/null 2>&1
schedule:run looks at the clock, runs whatever is due and exits. Locally, php artisan schedule:work does the same in the foreground once a minute, and php artisan schedule:test lets you pick a task and run it now.
Add your own
A job goes in app/Jobs, a command in app/Console/Commands (registered automatically), and its schedule in routes/console.php:
Schedule::command('orders:archive-old')->dailyAt('03:00');
Schedule::job(new ArchiveOldOrders)->weeklyOn(1, '04:00');
A scheduled command that takes long, or that must not overlap with itself, gets ->withoutOverlapping(); one that should run on one server only when you have several gets ->onOneServer(), which needs a shared cache store.