Loading...

This is taking longer than expected.

Back to the help centre

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.