starfruitsolutions / weft
Concurrent HTTP for PHP: Fiber workflows over one long-lived curl_multi handle.
Requires
- php: >=8.4
- ext-curl: *
- ext-json: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-25 03:01:32 UTC
README
Concurrent HTTP for PHP 8.4+. On a loom, the weft is the yarn that runs across the warp. Here, workflows share one curl_multi handle and resume as their own transfers finish.
request() is one HTTP call. run() overlaps several callables that each call request() and/or nested run(). Nested run() joins children on the same event loop, so siblings keep making progress.
Direct: several independent requests. One request() per closure.
use Weft\Weft; $weft = new Weft(); [$a, $b] = $weft->run( fn() => $weft->request(method: 'GET', url: 'https://example.com/a'), fn() => $weft->request(method: 'GET', url: 'https://example.com/b'), );
Composed: several multi-step procedures that may branch between hops. Each closure is sequential; request() inside it suspends so the other closures can keep going.
[$orderA, $orderB] = $weft->run( fn() => $this->submitOrder($weft, $idA), fn() => $this->submitOrder($weft, $idB), ); function submitOrder(Weft $weft, string $id): Result { $submit = $weft->request( method: 'POST', url: "https://api.example.com/orders/{$id}", ); if (!$submit->isError()) return $submit; return $weft->request( method: 'POST', url: "https://api.example.com/orders/{$id}", body: ['force' => true], ); }
request() outside run() is a one-shot of a single call. If that workflow throws, request() rethrows.
A procedure can nest run() for a fan-out in the middle of a sequence:
function loadOrder(Weft $weft, string $id): array { $order = $weft->request(method: 'GET', url: "https://api.example.com/orders/{$id}"); [$items, $customer] = $weft->run( fn() => $weft->request(method: 'GET', url: "https://api.example.com/orders/{$id}/items"), fn() => $weft->request(method: 'GET', url: "https://api.example.com/customers/{$order['customerId']}"), ); return [$order, $items, $customer]; }
Install
composer require starfruitsolutions/weft
Until the package is on Packagist, add the GitHub repo:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/starfruitsolutions/weft"
}
],
"require": {
"php": ">=8.4",
"starfruitsolutions/weft": "dev-main"
}
}
Requires PHP 8.4+, ext-curl, and ext-json.
request()
Bodies are JSON. context is opaque to Weft — hooks may read keys they care about.
$result = $weft->request( method: 'POST', url: 'https://api.example.com/items', body: ['name' => 'Widget'], context: ['pool' => 'writes', 'label' => 'createItem'], );
JSON object responses are ArrayAccess. Non-JSON bodies and empty responses become an error payload. httpCode is the transport status (0 on failure).
if ($result->isError()) { // $result['error'] } $code = $result->httpCode; $array = $result->toArray();
Result::QuotaExceeded($pool, $retryAt) builds a 429 without performing HTTP, for hooks that deny a request up front.
Workflows
run() starts one fiber per callable. A workflow that throws is stored as that Throwable at its index; run() itself does not throw, so sibling results stay available.
Inside a workflow, request() suspends until that handle finishes. Nested run() suspends the parent until its children finish. Other workflows keep transferring on the same curl_multi.
Workflow concurrency (not request count) defaults to 25. Parents waiting on a nested run() do not count toward the limit:
$weft->setConcurrency(50);
Retries
Extra attempts for transport failures (httpCode 0) and 5xx. Default is 0. Retries re-send; they do not re-run SendHook::before().
$weft = new Weft(retries: 2); // or later: $weft->setRetries(2);
Hooks
SendHook is a before/after seam. before() returning a Result skips curl. after() runs only for requests that actually transferred. Hooks see the internal Request object.
use Weft\Request; use Weft\Result; use Weft\SendHook; use Weft\Weft; final class RateLimitHook implements SendHook { public function before(Request $request): ?Result { $pool = $request->context['pool'] ?? ''; if ($this->isExhausted($pool)) return Result::QuotaExceeded(pool: $pool); return null; } public function after(Request $request, Result $result, float $durationMs): void { $this->record($request, $result, $durationMs); } } $weft = new Weft(hook: new RateLimitHook());
Default headers are a callable invoked once per root run() and reused for every handle in that run (auth tokens, etc.):
$weft = new Weft( defaultHeaders: fn(): array => ['Authorization: Bearer ' . $this->token()], );
Timeouts default to 30s / 10s connect. Pass timeout and connectTimeout to the constructor. Transfers use HTTP/2 over TLS when curl supports it, and accept compressed encodings.
License
MIT