parallite/parallite-php
Parallite is a lightweight PHP package for running tasks in parallel, with a simple API for concurrent jobs and result collection. It integrates with Laravel and helps speed up independent operations without extra infrastructure, making it useful for batch processing and I/O-bound work.
Install
composer require parallite/parallite-php
No service provider registration, no config file, no queue worker required — the package markets itself as zero-config, so start there and only look elsewhere if something misbehaves.
Where to look first
run() / parallel() helper) is the entire day-to-day surface. Skim its signature: what it accepts (array of closures? named tasks?), what it returns (array of results in submission order? a result object?).composer.json for ext-* requirements (pcntl, posix, parallel, sockets). "Zero configuration" usually means "no config file" not "no extensions", and this determines whether it runs on your target hosts (see Gotchas).First use case The lowest-risk, highest-payoff introduction is a fan-out over independent I/O: three or four outbound HTTP calls that currently run back-to-back inside a controller or artisan command.
use Parallite\Parallite; // adjust to the real namespace
$results = Parallite::run([
'weather' => fn () => Http::get('https://api.example.com/weather')->json(),
'rates' => fn () => Http::get('https://api.example.com/rates')->json(),
'profile' => fn () => Http::get('https://api.example.com/profile')->json(),
]);
If each call takes ~200 ms sequentially, this should land near the slowest single call. Measure before and after with microtime(true) — the "3x faster" claim is workload-dependent, and you want a baseline in your app before you refactor anything else.
Do not start with Eloquent queries or anything holding a PDO handle. Start with pure HTTP + array transforms, which are the safe subset.
Fan-out over independent external calls (primary pattern). Group the work that has no ordering dependency: geocoding, currency conversion, webhook fan-out, multi-provider lookups (Stripe + internal ledger + tax service). Keep each closure a self-contained unit: build the HTTP request inside the closure rather than capturing a pre-configured client.
Chunked data processing for CLI commands. For an artisan import/export that iterates 10k+ rows, split the payload into N chunks and hand each chunk to a closure. This is where you actually get 3x — with a warm CPU and enough work per task to amortize process startup.
$chunks = collect($rows)->chunk(ceil($rows->count() / 8));
$results = Parallite::run(
$chunks->map(fn ($chunk) => fn () => $this->processor->handle($chunk->all()))->all()
);
Wrap parallelism in a service, not in the controller. Per the architecture guidance in this repo: controllers stay thin, services orchestrate. Put the fan-out inside a ReportAggregator / BulkSync service, return a structured result object, and let the controller or command format it. This keeps the parallel surface testable and lets you swap to a sequential implementation for tests.
readonly class AggregatedReport
{
public function __construct(
public array $sections,
public array $errors,
public float $durationMs,
) {}
public function isSuccess(): bool { return $this->errors === []; }
}
Use it from a queued job, not as a replacement for queues. Queues handle durability, retries, backoff, and distribution. Parallite handles latency inside one request/one job. The idiomatic Laravel composition is: one job per logical unit of work → inside that job, fan out independent sub-tasks in parallel → return a result object → the job reports success/failure. Don't try to make Parallite replace Horizon.
Combine with Http::pool() where it fits. Laravel already ships Guzzle's pool for concurrent HTTP. If all your parallel work is HTTP, Http::pool() is simpler and has no process overhead. Reach for Parallite when you need to run mixed workloads (HTTP + filesystem + CPU-bound transforms) or when the unit of work is genuinely arbitrary PHP.
Pre-warm and pass plain data. Shape your closures to accept only scalars, arrays, and DTOs that are trivially serializable. Build HTTP clients, loggers, and DB connections inside the closure from $_ENV / config() values captured as plain strings.
$apiKey = config('services.reports.key'); // capture a string
Parallite::run([
fn () => Http::withToken($apiKey)->get(/* ... */)->json(),
]);
Closures serialise — the container does not come with them. This is the big one. A closure that captures $this, an Eloquent model, a Request, a LoggerInterface, or a PDO instance will either fail to serialise, silently produce broken state, or blow up in the child process. Rule of thumb: if it wouldn't survive serialize()/unserialize(), don't capture it. Re-resolve dependencies inside the closure.
No Laravel container in the child. app(), Auth::user(), request(), and facades that touch the current request are unreliable or unavailable. If a child needs config, read it in the parent, pass it as a scalar, and pass it into the closure. If a child needs the DB, open a fresh connection inside the closure with DB::purge() / a new Connection instance.
Connection exhaustion. N parallel workers × 1 fresh DB connection each is fine; N parallel workers × a pool of 10 is not. Cap concurrency deliberately — this package's default may be nproc, which on a shared host or a container with a CPU limit is a foot-gun. Watch SHOW PROCESSLIST during your first parallel run.
Results ordering is your responsibility to verify. Confirm from the tests whether the returned array preserves key order. Most implementations do for associative input, but the "3x" claim is often achieved by returning completion order for lists. Always key your tasks and index by key, not position.
Error handling is per-task, not per-batch. A single failing closure should not take down the other seven. Check the result shape: does it throw on the first failure, or return a per-task result/exception object? Wrap each closure body in your own try/catch and return a typed result — you lose almost nothing on throughput and gain a debuggable trail.
fn () => rescue(fn () => Http::get($url)->json(), fn ($e) => ['error' => $e->getMessage()], true)
Logging from children is asynchronous and unordered. Write to a per-task log channel with a task ID, or buffer strings and return them for the parent to log. Don't expect Log::info() calls from inside workers to appear in request order in storage/logs/laravel.log.
Memory: parent + N children. Peak RSS is roughly parent + N × child. On a 512 MB FPM worker this bites quickly. Either keep per-task payloads small or lower concurrency. Set memory_limit explicitly per child if the library exposes it.
Timeouts and orphans. If the parent request or job is killed (FPM max_execution_time, queue:work --timeout), children can be orphaned. Verify the library reaps children; test by killing the parent manually and checking ps aux | grep php. Set a wall-clock timeout on any external call inside the closure regardless.
Windows and restrictive hosts. Any fork- or proc-based implementation behaves differently on Windows and on shared hosting that disables proc_open/pcntl. Add a runtime guard, or feature-flag the parallel path and fall back to sequential.
Debugging is harder than dd(). Xdebug cannot follow a forked child cleanly. Debug by having each closure write to a unique temp file, or by returning diagnostic arrays and inspecting them in the parent. Establish a fallback path in your service (if (app()->environment('local') || $forceSequential) { … }) that runs the same closures in a foreach — invaluable for tests and reproducing bugs.
Testing. Unit-test the service that orchestrates the fan-out with parallelism disabled. Integration-test the parallel path once, asserting only on shape and totals, never on ordering or timing. Use Bus::fake() / Http::fake() as usual — fakes work fine inside child closures as long as they're re-established inside the closure, not captured from the parent context.
When not to use it. Single task (no fan-out), CPU-bound work that already saturates every core, or work that belongs in a queued job for durability. The overhead of process spawn is ~10–50 ms per child; if a task takes less than that, parallelism loses to sequential. Benchmark the real workload, not a loop of sleep().
How can I help you explore Laravel packages today?