vxm/laravel-async
vxm/laravel-async brings asynchronous and parallel execution to Laravel. Run closures and tasks concurrently with a simple API, await results, handle errors, and cap concurrency—ideal for speeding up slow I/O, reports, and batch jobs without restructuring your app.
Install the package and let Laravel discover the service provider:
composer require vxm/laravel-async # ^5.0 for Laravel 11; ^4.x for Laravel 10 and below
php artisan vendor:publish --provider="VXM\Async\AsyncServiceProvider" # publishes config/async.php
Laravel 11 requires v5.0.0 or later. If you are on Laravel 11 and Composer resolves to an older line (or refuses to resolve at all), that is the version matrix talking, not a broken package — check the constraint in composer.json before debugging anything else.
Then look at, in order: the package README (the authoritative API surface for your installed version), config/async.php (the only real config knob set — typically the PHP binary path used to spawn child processes), and src/Async.php / the Async facade, which is the entry point you'll actually call.
The smallest useful thing is fire-and-forget from a controller:
use VXM\Async\Facades\Async;
public function store(Request $request)
{
$order = Order::create($request->validated());
Async::run(function () use ($order) {
Log::info('order created', ['id' => $order->id]);
// e.g. hit a webhook, warm a cache, send an internal notification
});
return response()->json($order, 201);
}
What you get is a separate PHP process that boots the Laravel application, unserializes the closure, and runs it — so the user's request returns immediately while the work happens elsewhere. It is not a queue: there is no retry, no persistence, no supervisor. Treat it as "parallel/side process execution within the lifetime of a request" and it will make sense.
Fan-out for latency. The strongest day-to-day use case is collapsing several sequential slow calls into parallel ones:
$ids = [1, 2, 3, 4];
foreach ($ids as $id) {
Async::run(function () use ($id) {
Cache::put("stats:$id", $this->api->stats($id), 300);
});
}
Async::wait(); // block until all child processes finish
// read results back out of cache/db
The pattern is: spawn N tasks → wait → collect from a shared store (Redis, cache, database). The parent process never receives a return value, so the shared store is your results channel, not a return type.
Capture IDs, not models. Because the closure is serialized and shipped to a fresh process, pass scalars ($order->id, an array of settings) and re-fetch the model inside the child. Serializing an Eloquent model mostly works but drags connection/state baggage and any touched relations with it.
Wrap slow third-party integrations. PDF rendering, image resizing, report exports, mail relay, ERP/SMS/CRM sync calls. Anything where the caller does not need the answer in-band.
Use inside queue jobs, not instead of them. The reliable pattern for production: a real queued job owns durability, retries, and failure alerting; inside that job, use Async::run to parallelize the ten API calls it needs to make. Do not replace ShouldQueue with Async::run for anything that must eventually happen.
Guard against runaway spawning. Child processes each boot a full Laravel app. Batch large loops: chunk a 500-item list into 20 chunks of 25 and process chunks sequentially, rather than firing 500 processes at once.
Testing. Assert on the side effect (row in the table, cache key written, log emitted), not on the async call itself. Where the package offers a fake/sync mode, use it in feature tests; otherwise keep async calls behind a thin application service you can mock.
auth()->user(), session(), request(), app() singletons you mutated, and in-memory static caches are all gone. Pass a user ID and call Auth::loginUsingId() (or better, Auth::loginUsingId($id, false)) inside the child if you need an actor.DB::transaction() and expect the child to see the row. Commit first, then dispatch — or use DB::afterCommit() / queue jobs with $afterCommit.try/catch with Log::error(...), or you will silently lose failures for days.config('async.php_binary') (or the php_binary key of the published config) must point at a real CLI binary. In containers, Forge, Vapor, and Herd, php is frequently not on PATH for the FPM user; use PHP_BINARY or an absolute path. This is the #1 "it works locally, does nothing in production" cause.max_execution_time configured for CLI, or get OOM-killed without writing anything. Keep tasks short (seconds, not minutes) and log duration.Async::wait() per iteration serializes everything and gives you the worst of both worlds — accumulate, then wait once.if (app()->runningUnitTests()) through your code.run/wait call sites should carry over unchanged. Treat it as a constraint change: bump the requirement, composer update vxm/laravel-async, run php artisan config:clear if you re-publish config, and re-run your feature tests. Because a major bump usually signals a dropped minimum framework version, check the version matrix before upgrading a Laravel 10-or-below app; and pin the major version in composer.json since the API surface is minimal and mostly stable. Older releases (pre-5) differ in exact run/wait naming — read the README for the version you actually installed.How can I help you explore Laravel packages today?