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 |
WeblaborMx\BillingCore\Jobs\AnnounceMeterAddedToPlan |
An administrator saves a plan that has subscribers with a postpaid meter it did not include before; one job per meter added | Tells the owner of every active subscription to that plan, one hundred at a time, that the plan now bills that meter |
App\Jobs\Media\PerformConversions |
A picture is uploaded to the media library. The upload itself runs during the request; the four thumbnails (64, 128, 256 and 512 pixels) are queued by the media library because QUEUE_CONVERSIONS_BY_DEFAULT is true by default |
Generates the thumbnails. When it fails for good it writes the missing thumbnails and the reason to the log |
App\Jobs\Media\FileDelete |
An image custom field is removed and the files saved in it are deleted with it | Deletes those files and frees their space. Every other deletion of a file runs during the request |
App\Jobs\AnswerAssistantQuestion |
Someone on the support team asks the support assistant a question | Writes the assistant's answer. It is tried once and may run for up to 900 seconds, so the worker's --timeout and the connection's retry_after have to allow it; when it fails the answer is marked as failed |
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 billing:close-meter-periods |
Every day at 02:00, only when FEATURE_ADDONS_ENABLED is on |
Closes the metered periods that ended and bills their usage on the subscription; a period whose charge fails is retried on the next runs and moves to the account's outstanding balance once the grace period below runs out. Declared in the Billing Core service provider; the whole cycle is in /help/meter-usage-and-packages#bill-usage-when-the-cycle-closes |
php artisan billing:enforce-past-due |
Every day at 03:00, only when FEATURE_PLANS_ENABLED is on |
Warns the subscriptions whose failed payment is close to the end of its grace period and cancels the ones whose grace period ran out. Declared in the Billing Core service provider; what it does is explained in The grace period after a failed payment |
php artisan billing:expire-trials |
Every day at 08:00, only when FEATURE_ADDONS_ENABLED is on |
Sends the notice for add-on trials ending within a day, once, and ends the trials whose time is up, with nothing charged. Declared in the Billing Core service provider; see /help/sell-add-ons#by-expiry |
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; the guide «Support tickets», available when the support tickets module is on, explains them |
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_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; the guide «Live support chat», available when the live chat is on, explains it |
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.
The grace period after a failed payment
A renewal the gateway could not charge does not take the plan away at once. When Stripe reports the failed invoice, the subscription is marked with the date of the failure and the amount owed, and its owner receives the failed-payment notification. A usage period that billing:close-meter-periods could not charge on a running subscription starts the same clock. From then on the subscription keeps its plan, its included quota and its dates, the billing page shows Payment failed with the date the grace period ends and a Retry payment button, and the gateway keeps retrying.
How long that lasts is set in config/billing-core.php under past_due:
| Variable | Default | What it sets |
|---|---|---|
BILLING_PAST_DUE_GRACE_DAYS |
15 |
The days, counted from the failure, the subscription keeps everything while the payment is retried. Also how long a failed usage period is retried before its amount moves to the outstanding balance |
BILLING_PAST_DUE_WARNING_DAYS |
3 |
How many days before the end of the grace period its owner is warned that the subscription is about to be cancelled |
Every day at 03:00 billing:enforce-past-due goes through the subscriptions that still have a failed payment:
- One whose grace period ends within
BILLING_PAST_DUE_WARNING_DAYSand that was not warned yet: its owner receives the notice that the subscription will be cancelled for non-payment. The warning goes out once. - One whose grace period ran out: its open invoices are voided in Stripe, the subscription is cancelled on the spot (when Stripe cannot be reached it is marked cancelled locally, and the error goes to the log), the failure is cleared, and the amount owed becomes the outstanding balance of the account, which the user settles in one payment. Its owner is told about that balance.
A payment that goes through before then clears the clock, and the subscription is not touched. The command prints how many subscriptions it warned and how many it cancelled, and running it by hand is safe.
Configure Stripe to keep retrying a failed payment without cancelling the subscription on its own: if Stripe cancels it first, it ends before this grace period does.
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.