Mastering the Concurrency Facade in Laravel

How to use Concurrency Facade in Laravel

Mastering the Concurrency Facade in Laravel


Using PHP traditionally means writing code that executes synchronously. Each line of code runs only after the previous one has finished. So in a regular Laravel controller or command, if you need to call three different third-party APIs (say, to check the payment status, sync the CRM, and calculate delivery cost), your app will perform these operations one after another. And if each API call takes about 800 ms on average, your customer will have to wait nearly 2.5 seconds before getting an HTTP response.

The traditional solution to the problem is to offload long-running operations to asynchronously processed queues which can be consumed by queue workers. But such a solution hardly seems appropriate if you need to get the result of these operations right in the current HTTP request. Laravel's new concurrency facilities, however, can help with that. Using the Concurrency facade, you can run PHP closures in parallel processes or forks, collect their results, and then continue processing.

The purpose of this article is to understand Laravel's concurrency layer and understand how you can use it in your projects. We'll also try to configure drivers for it, pass context variables to asynchronous jobs, and get results without incurring any memory overhead.

How Concurrency::run Works

The Concurrency facade performs an array of closures concurrently. Laravel dispatches each closure to an isolated child process or fork, wait for all of them to resolve, and returns an array with the outputs in the matching key order.

Take the sequential API problem we've been talking about before. Running those requests in parallel would collapse the total wait time down to the duration of the slowest request:

use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Http;

[$rates, $crmStatus, $taxDetails] = Concurrency::run([
    fn () => Http::timeout(3)->get('https://api.shipping.test/rates')->json(),
    fn () => Http::timeout(3)->get('https://api.crm.test/customer/status')->json(),
    fn () => Http::timeout(3)->get('https://api.tax.test/rates')->json(),
]);

If each request takes 800 milliseconds, running them one by one takes ~2,400ms. With Concurrency::run(), all three run parallaly, and the entire block resolves in ~800ms.

Configuring Concurrency Drivers

Laravel ships with three built-in concurrency drivers. You may set your default driver in config/concurrency.php or publish the config with php artisan config:publish concurrency:

  • process (Default): Closures are serialized and executed via separate CLI sub-processes using Symfony's Process component (PHP Artisan concurrency:invoke is used under-the-hood). Works reliably across all platforms including Windows, Mac OS X, and Linux.
  • fork: Uses PHP's native pcntl_fork() extension to fork the current process. This has less overhead than the process supervisor since no new PHP process needs to be bootstrapped, but requires the pcntl extension and is only available on Unix-like systems (Linux/Mac OS X) and CLI or daemon workers (such as Laravel Octane).
  • sync: Executes the closures in the same process synchronously. Useful for local development, unit testing, or in environments that do not allow process forking.

You can also force a specific driver on the fly using the driver() method:

// Explicitly use the fork driver for performance in Linux/Octane environments
$results = Concurrency::driver('fork')->run([
    fn () => computeComplexMatrix(),
    fn () => aggregateMetrics(),
]);

Real-World Practical Example: Dashboard Data Aggregation

Let's design a controller that will show an executive analytics dashboard. The page needs to show the number of sales, support tickets SLA, and the server infrastructure’s state.

namespace App\Http\Controllers;

use App\Models\Order;
use App\Models\Ticket;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Http;

class ExecutiveDashboardController extends Controller
{
    public function index(): JsonResponse
    {
        // Execute three distinct operations concurrently
        [$monthlyRevenue, $unresolvedTickets, $cloudStatus] = Concurrency::run([
            // Task 1: Heavy database aggregation
            fn () => Order::where('created_at', '>=', now()->startOfMonth())
                ->where('status', 'completed')
                ->sum('total_amount'),

            // Task 2: Another database query
            fn () => Ticket::whereNull('resolved_at')
                ->where('priority', 'urgent')
                ->count(),

            // Task 3: Third-party status check via external HTTP API
            fn () => Http::timeout(2)
                ->get('https://status.cloudprovider.test/api/v2/summary.json')
                ->json('status.description') ?? 'Operational',
        ]);

        return response()->json([
            'monthly_revenue'    => $monthlyRevenue,
            'urgent_tickets'     => $unresolvedTickets,
            'cloud_status'       => $cloudStatus,
            'rendered_timestamp' => now()->toIso8601String(),
        ]);
    }
}

Instead of stacking the database query times on top of the external HTTP timeout, all three tasks progress in parallel across isolated processes, cutting down latency significantly.

Handling Context, State, and Models

Because the default process driver serializes closures and executes them in an external PHP CLI invocation, variables outside the closure have to be passed in through the standard use ($var) statement, and furthermore are required to be [serializable].

1. Passing Eloquent Models

Laravel automatically serializes Eloquent models using its ModelIdentifier contract (similar to how queued jobs work). When the child process runs, it re-fetches a clean, fresh instance of the model from the database:

use App\Models\User;
use Illuminate\Support\Facades\Concurrency;

$user = User::findOrFail($userId);

[$profileScore, $activityLog] = Concurrency::run([
    fn () => calculateScoreForUser($user),
    fn () => fetchRecentActivityForUser($user),
]);

2. Authentication & Request Context

In standard PHP sub-processes, global state such as that returned by auth()->user() or custom request headers will not be available. However, Laravel's concurrency layer will capture the context binding for you (such as the authenticated user and application locale).

If you have custom application state or service containers which require manual propagation you may bind custom context dehydrators using the Concurrency::createCustomContextUsing() hook within your AppServiceProvider.

Error Handling & Process Failures

What if one of the concurrent closures throws an unhandled exception or times out ?

By default, if any one of the child tasks throws an exception, the parent call will immediately cancel, bubbling up the exception to your error handler in your app. If Task 1 succeeds, but Task 2 throws an HttpException (say), concurrency::run() will rethrow the exception from Task 2:

use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Log;
use Throwable;

try {
    [$inventory, $pricing] = Concurrency::run([
        fn () => checkWarehouseInventory($sku),
        fn () => fetchDynamicSupplierPrice($sku),
    ]);
} catch (Throwable $e) {
    Log::error('Parallel task failed: ' . $e->getMessage());
    
    // Fallback values if parallel operations fail
    $inventory = 0;
    $pricing = null;
}

If you want to catch errors on individual tasks without failing the entire batch, catch exceptions inside the task closure itself and return a fallback or null.

Deferred Parallel Tasks: Concurrency::defer

Sometimes you want tasks to run concurrently, but you do not care about their results before sending the HTTP response to the browser. For example, logging audit trails, updating cache warms or pinging analytics.

In such cases use Concurrency::defer(). It schedules the closures to run concurrently right after the HTTP response has been flushed to the client keeping the page load times near instant:

namespace App\Http\Controllers;

use App\Models\Document;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Concurrency;

class DocumentController extends Controller
{
    public function download(Request $request, Document $document)
    {
        // Defer background metrics concurrently after response is sent
        Concurrency::defer([
            fn () => $document->increment('download_count'),
            fn () => logAnalyticsEvent('document_downloaded', $document->id),
        ]);

        // Browser receives the download immediately without waiting for the tasks above
        return response()->download(storage_path("app/{$document->file_path}"));
    }
}

Driver Comparison & System Compatibility

Driver Mechanism OS Support Execution Overhead Best Use Case
process Spawns sub-process via CLI (php artisan concurrency:invoke) All (Linux, macOS, Windows) Medium (Boots a minimal framework instance) Standard FPM applications needing cross-platform reliability.
fork Direct memory copy via pcntl_fork() Unix-only (Linux/macOS) Very Low (Zero framework reboot) High-performance environments, CLI commands, and Laravel Octane.
sync Runs inline sequentially All Zero Local unit tests and debugging environments.

Important Things to Remember

  • 📌 DB Connections Limits: A parallel process initiated by the process driver will open its own connection to the database so running 10 closures concurrently each of which is run 50 times at the same time will open 500 database connections. Keep the size of the task batches small (2-4 tasks).
  • Closures Should Be Fully Serializable: When using the process driver do not bind un-serializable resources (active file stream pointers $handle = fopen(...) etc.) to your closure's use() scope. Instead pass the resource identifier (ID or file path) and open the resource inside the closure.
  • ⚠️ Do Not Share Cache Locks Between Forks: If you use the fork driver keep in mind that memory is copied so if a closure modifies static memory or tries to release an atomic lock that was acquired by the parent process unpredictable race conditions may occur. Treat each closure instance as a sandbox.

Conclusion

The Concurrency facade acts as an abstraction layer between long and slow synchronous execution and more performant but complicated asynchronous queue pipelines. By offloading heavy HTTP requests and database aggregations to Concurrency::run(), you can significantly reduce the perceived response time of a request, usually down to the total time of the slowest query or request, while also keeping your code readable.

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬


Previous Post Next Post

Contact Form