BookStack is a Laravel application, and almost everything it does in the background flows through Laravel's queue system. Out of the box the queue connection is set to sync, which means an action like sending a notification email or firing a webhook happens inside the same web request that triggered it. The person saving a page waits while the SMTP handshake and message delivery complete. On a managed LiteSpeed account that often feels acceptable until you enable the content notification system introduced in v23.08, at which point a single page edit can fan out into several emails and the editor stares at a spinning browser tab.

Switching the queue to asynchronous processing moves that work off the request path, but it introduces the problem this article exists to solve. The official BookStack documentation assumes you can create a systemd unit that keeps php artisan queue:work alive forever as a daemon. On Hostiso's Managed Cloud Shared Hosting you have no root, no systemctl, and no Supervisor. The fix is to stop thinking of the worker as a permanent process and instead drive it in short, self-terminating bursts from the account cron scheduler.

How BookStack's queue actually works

BookStack's queue behaviour is controlled entirely through the .env file in your installation root, which you can edit from the cPanel or DirectAdmin File Manager. The relevant config file, app/Config/queue.php, carries an explicit warning that it should not be edited directly and that all changes belong in .env. The default connection reads from an environment variable:

QUEUE_CONNECTION=database

The three supported drivers are sync, database, and redis. Redis is rarely available on shared accounts, and sync defeats the purpose, so database is the practical choice. With this driver, each queued job is written as a row into a jobs table in your MySQL database, failed attempts land in failed_jobs, and nothing is lost if the worker is not running at the moment a job is created. The jobs simply wait in the table until a worker picks them up.

Those tables are created by BookStack's own migrations, so a normal install or upgrade already has them. You can confirm this in phpMyAdmin: open your BookStack database and look for jobs, job_batches, and failed_jobs. If the jobs table is present but has rows that never disappear, that is the clearest sign your worker is not executing. If the table is missing entirely, the schema is incomplete and you should run the pending migrations through your usual upgrade routine before going further.

The worker command itself is php artisan queue:work. Each invocation boots the full framework, then loops pulling rows from the jobs table. The important flags for a shared environment are --stop-when-empty, which makes the process exit once no jobs remain, --max-time, which caps the total runtime in seconds, and --tries, which limits retry attempts before a job is marked failed. Without one of the exit conditions, the process would run until the host's CPU-time limit kills it, leaving a messy half-finished state.

Driving the worker from cron instead of a daemon

The cron scheduler in cPanel lives under Advanced → Cron Jobs; in DirectAdmin Evolution it is under Advanced Features → Cron Jobs. The single most common reason a cron-driven worker fails silently is the PHP binary. A cron command that just says php may resolve to a different version or a CGI build rather than the CLI build, and CLI is what Artisan needs. On CloudLinux accounts the per-account CLI binary that respects your PHP Selector version sits at a path like /usr/local/bin/ea-php82 or is exposed through /opt/alt/php82/usr/bin/php. Before scheduling anything, you can verify the version by adding a one-off cron entry such as /usr/local/bin/ea-php82 -v and mailing yourself the output; the first line should contain (cli).

Once you know the correct binary and the absolute path to your installation, a workable entry that fires every minute looks like this:

* * * * * /usr/local/bin/ea-php82 /home/username/public_html/artisan queue:work --stop-when-empty --tries=3 --max-time=55 >> /home/username/bookstack-queue.log 2>&1

Replace username and the document root to match your account. The --stop-when-empty flag ensures the process ends as soon as the queue drains, so you are not running a permanent daemon and you are not leaving orphaned processes to pile up in memory. The --max-time=55 cap keeps a single run comfortably under the one-minute interval, which prevents two overlapping workers from racing on the same jobs rows. Redirecting output to a log file in your home directory, rather than to /dev/null, is worth doing during setup because it captures any fatal errors; once everything is stable you can point it at /dev/null to avoid growing the file indefinitely.

Notification and webhook latency is tied directly to how often this runs. A one-minute interval means an edit's emails go out within roughly a minute, which is acceptable for most documentation teams. If your plan's cron frequency is restricted, a five-minute interval still works; subscribers simply wait a little longer. There is no benefit to running more aggressively than once per minute, and doing so risks tripping resource limits.

Troubleshooting stuck, silent, or failing jobs

When notifications do not arrive, resist the urge to blame SMTP first. Start by establishing whether jobs are being created and whether they are being consumed. Open phpMyAdmin and run a count on the queue table:

SELECT COUNT(*) FROM jobs;

If that number climbs over several minutes and never falls, the problem is the worker, not the mail configuration. The jobs are queuing correctly but nothing is draining them, which points to the cron entry. Re-check the PHP binary path, confirm the absolute path to artisan is correct, and read the log file you set up. A line such as Could not open input file: artisan means the path is wrong; a PHP version error means your cron binary differs from the PHP Selector version your install expects.

If the jobs table stays empty even after you edit a page, then the queue connection is still sync or the .env change did not take effect. Laravel caches configuration, and BookStack can hold a cached config. Clearing it is a legitimate Artisan task you can also run from a one-off cron line: /usr/local/bin/ea-php82 /home/username/public_html/artisan config:clear. After that, test again.

Failed jobs are their own category. When a job exhausts its --tries attempts it is moved to the failed_jobs table with a full exception payload. Inspect the exception column of the most recent row in phpMyAdmin; it usually names the real cause, most often an SMTP authentication error, a TLS handshake failure, or a webhook endpoint returning a non-2xx status. For outbound delivery problems specifically, cURL and TLS behaviour on shared hosting is covered in depth in our companion article, handling outbound webhooks, cURL/TLS, and API auth. Once you have fixed the root cause, you do not need to recreate the jobs by hand; a one-off queue:retry all cron line pushes every failed row back onto the queue, and the next scheduled worker run will process them.

Two application-level logs round out the picture. BookStack writes to storage/logs/laravel.log within the installation, and your account's PHP errors surface in the per-directory error_log file that LiteSpeed drops next to the executing script. Worker crashes caused by exceeding the per-process memory limit set in your .user.ini will appear there as fatal allocation errors; if you see those, lower the amount of work per burst by relying on --stop-when-empty rather than trying to raise limits you do not control. A worker that boots, drains the queue, and exits cleanly within its time cap is the stable pattern this environment rewards.