Processes, Concurrency and Context

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with laravel/pennant 1.26.0. Four smaller features are grouped here: Process runs other programs, Concurrency runs PHP closures in parallel, Context carries request data into logs, jobs and child processes, and Pennant switches features on per user.

Running processes

The Process facade wraps Symfony Process. An array command is passed to the program without a shell, so an argument cannot be split or interpreted, however it is written:

<?php
use Illuminate\Process\Pool;
use Illuminate\Support\Facades\Process;

$result = Process::path(base_path())->run(['php', 'artisan', 'books:count']);
$result->exitCode(); $result->successful(); $result->output();

$failed = Process::run(['php', '-r', 'fwrite(STDERR, "boom\n"); exit(3);']);
$failed->errorOutput();
$failed->throw();                                       // ProcessFailedException

$isbn = '978-0-8070-8305-4; rm -rf /tmp/x';
Process::run(['php', '-r', 'echo $argv[1];', $isbn]);   // one argument, no shell

Process::timeout(1)->run(['sleep', '3']);               // ProcessTimedOutException

Process::pool(fn (Pool $pool) => [
    $pool->as('a')->command(['sleep', '1']),
    $pool->as('b')->command(['sleep', '1']),
    $pool->as('c')->command(['sleep', '1']),
])->start()->wait();

$process = Process::start(['php', '-r', 'foreach (range(1, 3) as $i) { echo "chunk $i\n"; usleep(300000); }']);
while ($process->running()) {
    echo $process->latestOutput();                      // read while it runs
    usleep(100000);
}
1. exit 0, successful true, output: 27 books in the catalogue
2. exit 3, errorOutput: boom
   throw(): ProcessFailedException
3. array command, argument passed as one: 978-0-8070-8305-4; rm -rf /tmp/x
4. ProcessTimedOutException after 1003 ms
5. pool of three 1 s sleeps: 1003 ms, all ok: true
   live: chunk 1 at 100 ms
   live: chunk 2 at 401 ms
   live: chunk 3 at 702 ms
  • A failed process does not throw by default. Exit code 3 was only visible through exitCode(). ->throw(), or Process::run(...)->throw() in one line, makes failures exceptions.

  • The default timeout is 60 seconds. A web request that runs a process should set a shorter one, and a long job should run it from a queued job with forever() or a matching #[Timeout].

  • A string command goes through sh -c. Process::run("convert $file out.png") lets a crafted file name run commands. That is the PHP equivalent of shell injection, so use arrays, or escapeshellarg() around each part.

Concurrency

Concurrency::run() runs closures in parallel and returns their results in order. The default process driver serializes each closure, starts php artisan invoke-serialized-closure for it, and reads the serialized result back:

<?php
use Illuminate\Support\Facades\{Concurrency, DB};

[$books, $slow1, $slow2] = Concurrency::run([
    fn () => DB::table('books')->count(),
    function () { usleep(500000); return getmypid(); },
    function () { usleep(500000); return getmypid(); },
]);

Concurrency::driver('sync')->run([fn () => usleep(500000), fn () => usleep(500000)]);

$big = str_repeat('x', 98_000);
Concurrency::run([fn () => strlen($big)]);              // captures $big
6. sequential (one count + one 500 ms task): 505 ms
   Concurrency::run: 736 ms, books=27, child pids 2770 2771, parent 2755
   sync driver: 1000 ms
7. captured 90 000, 95 000, 97 000 bytes: ok
   captured 98 000 bytes: ProcessStartFailedException: The command "'/usr/bin/php8.3' 'artisan' invoke-serialized-closure" failed.

Three costs show in the timings and the error:

  • Each task boots the framework. Two parallel 500 ms tasks took 736 ms, not 500. Each child process booted Laravel in about 230 ms here. Parallel tasks pay off for work of a second or more, such as several slow API calls or reports. They lose for anything shorter. The fork driver (composer require spatie/fork) skips the boot, but it only works in the CLI, because PHP cannot fork in a web request.

  • The closure travels in an environment variable. ProcessDriver passes it base64-encoded in LARAVEL_INVOKABLE_CLOSURE, and Linux limits one environment string to 128 KiB (MAX_ARG_STRLEN). Base64 adds a third, so a closure that captured 98,000 bytes could not start. Pass ids and load data in the task.

  • Results come back serialized, so they must be serializable. Closures and resources cannot be returned.

Concurrency::defer([...]) runs the closures after the response has been sent, for work the user does not wait for. For that, a queued job is the more robust choice, since it survives a crash and is retried (Queues and Jobs).

Context

The Context facade holds data for the current request or job. It is added to every log entry, serialized into every job dispatched, and restored in the worker. The basics, Context::add() and addHidden() for values that travel but are not logged, are on Error Handling and Logging. Beyond jobs:

<?php
use Illuminate\Support\Facades\{Concurrency, Context, Process};

Context::add('trace_id', 'tr-0192');
Context::addHidden('member_id', 2);
Context::push('breadcrumbs', 'catalogue', 'book:1');      // a stack

Concurrency::run([fn () => [Context::get('trace_id'), Context::getHidden('member_id')]]);
Process::run(['php', 'artisan', 'tinker', '--execute', 'echo json_encode(Context::all());']);
Context::scope(fn () => Context::all(), ['step' => 'import']);   // extra data only inside the closure
Concurrency child sees: ["tr-0192",2]
Process child sees: []
scope: {"trace_id":"tr-0192","breadcrumbs":["catalogue","book:1"],"step":"import"} then {"trace_id":"tr-0192","breadcrumbs":["catalogue","book:1"]}
dehydrated keys for a job payload: ["data","hidden"]

Concurrency tasks receive the context, hidden values included. A plain Process does not, because it is an unrelated program. To follow a trace into it, pass the id as an argument or an environment variable (Process::env(['TRACE_ID' => Context::get('trace_id')])). The same id can go out in HTTP calls with a global middleware (HTTP Client).

Feature flags with Pennant

Pennant (composer require laravel/pennant, then publishing and running its migration) decides per scope, usually the user, whether a feature is on. The answer is stored, by default in a features table, so a user who got the feature keeps it:

<?php
// AppServiceProvider::boot()
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;

Feature::define('new-search', fn (User $user) => match (true) {
    $user->hasRole(Role::Librarian, Role::Admin) => true,   // staff first
    default => Lottery::odds(1, 4)->choose(),                 // then 25% of members
});

Feature::define('loan-days', fn (User $user) => $user->hasRole(Role::Librarian) ? 42 : 21);   // a rich value

Forty new members and one librarian:

<?php
$members->filter(fn ($user) => Feature::for($user)->active('new-search'))->count();
Feature::for($librarian)->value('loan-days');
Feature::activateForEveryone('new-search');
Feature::purge('new-search');
members with new-search, first run: 12/40
second run (stored results): 12/40
librarian: true, loan-days: 42, member loan-days: 21
rows in features: 43, sample: {"name":"new-search","scope":"App\\Models\\User|10","value":"false"}
after activateForEveryone: 40/40
after purge, rows for new-search: 0
  • The lottery ran once per user. Twelve of forty (30%, near the 25% odds) got the feature, and the second run after clearing the in-memory cache read the same twelve from the table. Without storage, a member would see the new search on one page and the old one on the next.

  • Negative answers are stored too. The sample row is "value":"false". Raising the odds later does not reach those members until purge() deletes their rows and the closure runs again.

  • Every check is a query the first time per request. Feature::for($users)->loadMissing([...]) loads many at once, in the same way as eager loading.

In Blade, @feature('new-search') … @else … @endfeature switches markup. The features middleware returns a 400 for routes behind an inactive feature. Feature::active('new-search') without for() uses the authenticated user.

Testing

<?php
// tests/Feature/ProcessAndFeatureTest.php
use App\Enums\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\{Concurrency, Process};
use Laravel\Pennant\Feature;

uses(RefreshDatabase::class);

test('the backup command shells out once', function () {
    Process::fake(['*pg_dump*' => Process::result(output: 'ok')]);     // 'pg_dump *' does not match an array command

    $result = Process::run(['pg_dump', '--format=custom', 'bookshelf']);

    expect($result->output())->toBe("ok\n");
    Process::assertRan(['pg_dump', '--format=custom', 'bookshelf']);
    Process::assertRanTimes(fn (PendingProcess $process) => $process->command[0] === 'pg_dump', 1);
});

test('concurrent tasks return in order', function () {
    expect(Concurrency::driver('sync')->run([fn () => 'a', fn () => 'b']))->toBe(['a', 'b']);
});

test('staff always get the new search', function () {
    expect(Feature::for(User::factory()->create(['role' => Role::Librarian]))->active('new-search'))->toBeTrue();
});

test('a member can be switched on for a test', function () {
    $member = User::factory()->create();
    Feature::for($member)->activate('new-search');

    expect(Feature::for($member)->active('new-search'))->toBeTrue()
        ->and(Feature::for($member)->value('loan-days'))->toBe(21);
});

All four pass. A Process::fake pattern is matched against the command string, and an array command is first turned into 'pg_dump' '--format=custom' 'bookshelf', with quotes. The pattern pg_dump * in the first version of the test matched only the string form, so the array command got the default empty fake, '', instead of ok. *pg_dump* and 'pg_dump' * matched both forms.

Where older books and tutorials are outdated

  • exec(), shell_exec() and backticks with string commands give no timeout or error output and invite injection. Process with array commands replaces them.

  • symfony/process used directly still works. The facade (Laravel 10) adds pools, fakes and assertions.

  • pcntl_fork() by hand for parallel work is replaced by Concurrency (Laravel 11), or by queued jobs.

  • config('features.x') booleans switch a feature for everyone at once. Pennant switches it per user and keeps the answer.