barryvdh/laravel-async-queue
Laravel Async Queue adds an async queue driver that runs queued jobs in background PHP processes instead of requiring a long-running queue worker. Useful on shared hosting or simple deployments where daemons aren’t available, while keeping Laravel’s queue API.
Verify the host before writing any code. This package spawns a background PHP process per job via spatie/async; if your host disables proc_open or has no discoverable PHP CLI binary, nothing else matters. Run this on the production image (not just your laptop/Valiant):
php -r "var_dump(function_exists('proc_open'));"
# then confirm the binary the package will discover:
php -r "var_dump(PHP_BINARY, PHP_SAPI, PHP_OS);"
If proc_open is unavailable or the CLI path is odd (common on hardened shared hosts, Forge-provisioned images with restricted disable_functions, and Vapor/ephemeral runtimes), stop — the package is not viable there.
Install and pin 0.8.1 explicitly. The 0.8.0 release has a deferred-service-provider defect that can fail to resolve the driver binding depending on boot order; 0.8.1 is the fix and is the only usable version on that line.
composer require barryvdh/laravel-async-queue:0.8.1
Register the connection alongside your existing one in config/queue.php. Do not replace default — keep the old driver intact so rollback is a config change, not a code change.
'connections' => [
'sync' => ['driver' => 'sync'],
// ...existing database/redis/sqs definition left untouched...
'async' => [
'driver' => 'async',
'table' => 'jobs', // backing store for the underlying worker
'queue' => 'default',
],
],
First use case — pick the least dangerous job in the app. Transactional email is the canonical choice. Write the job so it is idempotent and safe to skip from day one, because you will not get retries later:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function __construct(public int $userId) {}
public function handle(): void
{
// guard against double-send; the process can die mid-flight
$user = User::find($this->userId);
if (! $user || $user->welcomed_at) {
return;
}
Mail::to($user)->send(new WelcomeMail($user));
$user->forceFill(['welcomed_at' => now()])->save();
}
}
Dispatch explicitly to the connection for the canary, so a misconfiguration on default can't swallow production mail:
SendWelcomeEmail::dispatch($user->id)->onConnection('async');
Where to look first:
config/queue.php → the async connection block (backing store, queue name, concurrency).AsyncQueue / connector source (vendor/barryvdh/laravel-async-queue/src/) — it is small; read it once so you understand exactly what is spawned.failed_jobs table and php artisan queue:failed — that is your entire failure-visibility story.spatie/async docs for process/pool semantics, since concurrency behavior lives there.Canary checklist before you ship: dispatch one job, tail storage/logs/laravel.log, confirm the process actually ran (a log line from handle()), confirm the jobs row disappeared, then induce a failure and confirm it lands in failed_jobs. If any of those four steps is silent, you have a hosting problem, not a package problem.
Keep default pointing at whatever you had (even sync), and opt jobs into async per-dispatch or per-job. This gives you a per-job escape hatch for jobs that do need durability:
class SendWelcomeEmail implements ShouldQueue
{
public $connection = 'async'; // opt-in per job class
}
Reserve the durable connection for anything financial or contractual, and let async carry the best-effort side effects. This is the single most valuable pattern for adopting an archived, best-effort driver — you are not betting the app on it.
Spawning a process per job is the cost model. Sub-5-second jobs amortize fine; a two-minute export will hit max_execution_time with no supervisor to notice or restart it. If a job is doing heavy lifting, split it: a short "kick off" job that writes a row / hits an internal endpoint, plus offline processing.
Serialized payloads are re-deserialized in the spawned process. Pass IDs and scalars, not models or large blobs:
// good
ReportRequested::dispatch($reportId);
// bad — heavy serialization, stale-state risk
ReportRequested::dispatch($report);
Every job must be safe to run twice and safe to never run. Standard patterns:
public function handle(): void
{
// 1. claim the work atomically
$claimed = DB::table('reports')
->where('id', $this->reportId)
->whereNull('started_at')
->update(['started_at' => now()]);
if (! $claimed) {
return; // another process got here first, or already done
}
// 2. do the work
$this->generateReport($this->reportId);
// 3. mark done
DB::table('reports')->where('id', $this->reportId)->update(['completed_at' => now()]);
}
Combine with a completed_at/welcomed_at guard (seen in Getting Started) or unique constraints on notification tables. This is the one discipline the assessment flags as a non-negotiable for this package: no retries are coming, so your code has to be its own safety net.
The package exposes process/concurrency configuration (backed by spatie/async). Set it explicitly rather than relying on defaults — a burst of dispatches that each spawn a process can exhaust memory or hit open-file limits on a small VPS. Tune down on shared hosting; tune up only after measuring.
// config/queue.php — async connection
'async' => [
'driver' => 'async',
'table' => 'jobs',
'queue' => 'default',
'concurrency' => 2, // conservative on small hosts
],
Treat this as a governor you must configure, not one you get for free.
The dangerous failures here are both silent dispatch success:
proc_open blocked at runtime).Instrument both. A lightweight pattern — a listener on JobProcessed / JobFailed plus a queue-depth vs. processed-count metric — makes the divergence visible:
use Illuminate\Support\Facades\Event;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobFailed;
Event::listen(JobProcessed::class, fn ($e) => metrics('queue.processed', 1, ['queue' => $e->job->getQueue()]));
Event::listen(JobFailed::class, fn ($e) => metrics('queue.failed', 1, ['queue' => $e->job->getQueue()]));
Then alert if jobs depth grows while queue.processed stays flat. That divergence is the signature of a broken host integration. Note the upstream assessment's explicit callout: failed-job visibility is limited to what the backing store and failed_jobs table expose — there is no Horizon equivalent, so this instrumentation is on you.
Write jobs so that swapping the driver is a config change. That means:
$this->release(), no manual retry loops, no reliance on backoff semantics.Bus::batch, RateLimited, etc.) on async jobs.ShouldQueue job classes driver-agnostic; the only driver-specific thing should be $connection or a per-dispatch onConnection() call.Then document the exit trigger in the codebase so nobody later assumes retries exist:
/**
* BEST-EFFORT JOB — runs on the `async` driver (barryvdh/laravel-async-queue).
* No retries, no backoff, no dead-letter guarantee. Must be idempotent and
* safe to skip. If this needs "must not be lost" semantics, move to `redis`
* and remove the $connection override. See ADR-00x.
*/
class SendWelcomeEmail implements ShouldQueue { /* ... */ }
0.8.1 so the dependency is an owned artifact. Keep the surface small so merging future Laravel compat fixes (if any are ever needed) stays cheap.composer.json ("barryvdh/laravel-async-queue": "0.8.1"), not ^0.8.queue:restart/queue:retry expectations calibrated. Operator muscle memory doesn't transfer cleanly here; these commands largely don't do what they do under Redis/SQS. Verify their behavior in staging before promising them to anyone.Hosting gate is the real risk, not the package. "Works locally" means nothing. Many hardened production images disable proc_open (via disable_functions) or have no CLI binary at the expected path. The failure surfaces at runtime (silently, on your first dispatch), not at install time. Run the platform gate on the real production image — this is the single most common way teams get burned by this package.
0.8.0 is not usable — pin 0.8.1. The 0.8.0 deferred-service-provider defect can fail to resolve the driver binding depending on when the queue connection is first touched relative to provider boot. That class of bug hides under Octane workers, in test suites that resolve early, and in partially-booted console commands. If you see "queue connection [async] not configured" or a null connector, first check your version.
Archived repo is the dominant long-term risk. No security patches, no future Laravel major compat, no upstream escalation. Treat adoption as forking a code sample into your estate, with a named internal owner. If nobody will own it, don't adopt it. Document the exit trigger (volume, a retry-requiring job, a critical job) and owner in the codebase.
Concurrency is not governed by default. A burst of dispatches can spawn a pile of processes — exhausting memory or file handles on a small VPS. Configure process/concurrency limits explicitly and load-test the burst case before assuming it's fine.
No retries, backoff, or dead-letter. The Laravel queue API exposes these; this driver does not deliver them durably. If your job class has $tries, $backoff, or $maxExceptions, those are not going to save you here — the process is spawned once and dies. Move anything with "must not be lost" semantics to a real broker. Write the constraint into the job class, not just the ticket.
Failed-job visibility is thin. There is no Horizon-equivalent. Your window is the failed_jobs table plus whatever the backing store exposes. If visibility matters, instrument JobProcessed/JobFailed early (see Implementation Patterns) and alert on queue-depth vs. processed-count divergence.
max_execution_time is a hard ceiling with no supervisor to restart you. Jobs that exceed it die silently. Keep jobs well under the limit — sub-30s at worst, sub-5s ideally. Long-running work (large exports, LLM calls, bulk syncs) belongs elsewhere.
Silent dispatch is the failure signature. Dispatching always looks successful even when the process spawn silently fails. Add the canary (log line from handle() + a row that disappears) to your smoke-test suite; it catches host misconfiguration before your users do.
Serialization is real work in the spawned process. Passing full Eloquent models (or large blobs) to the constructor re-hydrates them in a separate PHP process — slow and, if the row changed, stale. Pass IDs.
queue:restart/queue:retry semantics differ. Because there's no long-running worker, these commands don't behave as operators expect from Redis/Horizon. Verify behavior in staging; don't promise a retry workflow that doesn't exist.
Extension points are narrow. You plug in at the framework seam (QueueManager driver registration, the connection config, the backing store). That's a feature, not a bug: it keeps your exit cheap (swap the driver in config) and your codebase clean (no package-specific APIs in jobs). Resist the urge to add package-specific logic to job classes — every such addition is a line of untangling you'll do later.
Test with real side effects, not mocks. Because the failure mode is process spawn, mock-based tests give false confidence. Integration tests that assert an observable side effect (row written, mail in fake transport, log line) are your safety net.
Keep the async connection alongside, never instead of, an existing one. Rollback becomes a config edit, not a code change. This also lets you route one job to redis later without touching job classes — provided you kept them driver-agnostic.
Write the contract where the next developer will see it. A docblock on every ShouldQueue job on this driver, and an ADR covering the best-effort guarantee, the exit trigger, and the named owner. Otherwise someone in six months will assume retries exist — exactly the failure mode the upstream assessment warns about.
How can I help you explore Laravel packages today?