PHP · Chain · Flow · cURL multi
Chain: fluent calls, reusable steps and concurrent HTTP
An object-call sequence, an application-step plan and an HTTP batch can share similar fluent syntax. Their state, stopping conditions and failure consequences differ. In Chain 2.0 I separated these tasks into Chain, Flow and Batch.
The same fluent syntax can hide different execution models
Reusing a chain requires knowing what it retains: an object already mutated, definitions of steps not yet executed, or network-request settings. Failures also have different scopes: one call, the remaining sequence, or a single job among independent requests. These distinctions belong in the tool’s contract.
The three PHP 8.5 utilities in Chain 2.0 have distinct responsibilities. Chain wraps an object and executes calls immediately. Flow stores a reusable plan of application steps. Batch manages independent HTTP requests through cURL multi. The common fluent syntax leaves their execution differences explicit.
| Utility | Stored state | When work executes |
|---|---|---|
| Chain | Current object and latest value | At each method call |
| Flow | Step plan, context and resolver | On run() or execute() |
| Batch | Requests, configuration and scheduler state | Inside a blocking run() |
The utilities can be combined, but none requires another. Batch does not dispatch requests through Chain, and Flow does not use the HTTP scheduler to execute its steps. The following decisions define their behavior.
Chain: the value changes while the receiver stays
The wrapper holds two pieces of state: the object receiving method calls and the latest operation value. When a method returns a number, string or null, the receiver stays. An object return becomes both the receiver and the current value. This allows transitions between related objects while still exposing scalar results.
Here next() returns an int. The following pipe() receives that number, while reset() still targets the original counter. A void method produces null; get() does not substitute the object.
use tommyknocker\chain\Chain;
$counter = new class {
private int $value = 0;
public function next(): int { return ++$this->value; }
public function reset(): void { $this->value = 0; }
};
$chain = Chain::of($counter);
$value = $chain->next()
->pipe(static fn (int $n): int => $n * 10)
->get(); // 10
$receiver = $chain->instance(); // $counter
$result = $chain->reset()->get(); // null
The distinction also applies to callbacks: tap() observes the current object, inspect() the current value. If an object method conflicts with the wrapper API, such as get() or map(), explicit invoke() calls the object method. Name resolution should not depend on the reader guessing.
Chain::clone() copies the wrapper and current object: shallowly by default, or deeply with enableDeepCloning. It branches existing state. Flow::then(), in contrast, copies a step plan; the payload is supplied only when executing it.
Dynamic transitions have a cost. PHPStan does not automatically infer every receiver type throughout the chain. requireInstance(Foo::class) returns a checked object of the requested type after a transition; fluent syntax alone does not make an arbitrary chain statically typed.
Flow: extend a plan without changing its base
Application steps need a different contract. A plan exists separately from a particular run: save it, append steps for one scenario, and retain the original for another. Consequently, then() returns a new Flow and each execution produces a separate FlowExecution.
A step can be a StepInterface, a class name resolved through a PSR-11 container, a callable, or a nested Flow. Named classes suit application actions with dependencies; short callbacks suit local transformations. The small example below demonstrates only planning and controlled stopping.
use tommyknocker\chain\Flow;
use tommyknocker\chain\FlowStop;
$base = Flow::of(static fn (string $s): string => trim($s));
$normalized = $base
->then(static fn (string $s): string|FlowStop =>
$s === '' ? Flow::stop('') : $s)
->then(static fn (string $s): string => strtolower($s));
$original = $base->run(' PHP '); // 'PHP'
$execution = $normalized->execute(' PHP ');
$result = $execution->unwrap(); // 'php'
$stopped = $normalized->execute(' ');
$didStop = $stopped->stopped(); // true
Flow::stop() is successful completion with a result. It stops both a nested and the outer Flow. A step exception has a different outcome: execute() returns a failure, while run() throws FlowFailedException with the step index, name and original cause. A nested failure retains the inner step details; it does not build a separate full nesting path.
Flow observer exceptions are suppressed, just as in Batch: an observer failure does not change the outcome of the steps.
Nested flows inherit context, with child attributes and resolver taking precedence. Immutability applies to the plan structure. Step objects, closure captures and payloads are not cloned. Reuse therefore requires stateless steps or state that is intentionally safe to share.
Batch: limit active requests without pausing the whole batch
For independent URLs, a sequential loop adds up network waiting time. Starting every request at once introduces another problem: input size determines connection count and load on the remote service. Batch keeps no more than the configured number of active transfers and starts queued jobs as slots become available.
A failed attempt may enter delayed retry. Until its due time, it occupies no active connection; other requests continue. When no transfers are active, the scheduler waits instead of continuously checking the clock. cURL multi execution and retry scheduling share one execution loop.
use tommyknocker\chain\Batch;
use tommyknocker\chain\HttpRetryPolicy;
// $urls: an id => URL map supplied by the caller.
$report = Batch::fetch($urls)
->concurrency(8)
->maxJobs(500)
->maxBodySize(2 * 1024 * 1024)
->timeout(10)
->retry(HttpRetryPolicy::transient(maxAttempts: 3))
->run();
$responses = $report->responses(); // Successful responses, keyed by id.
$failures = $report->failures(); // Final failures, keyed by id.
- Queued → activeA job takes an available active-transfer slot.
-
success retry final failureResponse or attempt failureSuccess enters Report; a retry waits without an active connection; final failure remains in the report.
- Delayed retry → queuedOnce due, the job competes for an available slot again. Cancellation stops the remaining work.
Concurrency here concerns network I/O. run() blocks its caller until completion or cancellation. It is neither a background executor nor parallel execution of PHP callbacks.
Each limit addresses a different resource. concurrency() limits active transfers, maxJobs() input jobs, and maxBodySize() one in-memory response. Completed responses remain in Report, and input is collected into a list before execution. Bounded concurrency therefore does not bound total batch memory. download() streams large bodies to files.
Attempt failure, job failure and cancellation are different events
After a transport error, the client may not know whether the server processed its request. An unconditional retry can repeat an action already performed. The standard HTTP policy therefore retries only GET and HEAD after transport errors, timeouts and statuses 408, 429, 502, 503 or 504. Other methods require explicit retryUnsafeMethods; that permission alone does not make an application operation idempotent.
Redirect policy also depends on the request. Automatic redirect following is disabled by default when an Authorization header or basic auth is present. followRedirects(true) explicitly enables it; the caller determines whether redirects are appropriate for a request carrying credentials.
onAttemptFailed() describes an unsuccessful attempt that will be retried. onFailed() describes a final job failure. A successful retry removes the earlier failure from the final report. Observer exceptions are suppressed: a logging exception must not turn a successful HTTP request into a failed batch.
By default, one failed job leaves other jobs running. failFast() stops further scheduling after final failure, including URL validation and file preparation. A preflight failure occurs before any requests start. cancel() lets application code or an observer stop remaining work; active transfers are released.
Cancellation does not roll back actions already performed on the server. The report retains known outcomes and errors, but adds no artificial failure for every job that never started. An empty failures list after cancellation therefore does not prove that the entire input was processed.
throwIfFailed() throws an exception retaining the report when failures exist. Successful HTTP operations and published files remain successful. The method lets callers require the absence of recorded errors; it does not turn a batch into a transaction.
A downloaded file becomes visible after validation
Writing directly to the final path lets another process read an incomplete file and can leave failed downloads under their final names. DownloadBatch writes into a temporary file in the target directory. HTTP status and expected Content-Type are checked before publication; an unsuccessful attempt removes the temporary file.
Two distinct filesystem operations are needed. With overwrite(), rename() publishes the completed file. Without replacement permission, link() must atomically fail if the target name is occupied. The temporary name is removed after a successful link.
A file_exists() check before downloading is insufficient: another process can create the target between checking and publishing. Atomic no-replace publication closes this race. Within one batch, target paths are also reserved so that two jobs cannot publish under the same name.
This requires a filesystem supporting hard links. Failure to create a link fails the job, without an unsafe fallback to overwriting. Atomic publication of one file implies neither atomicity of the entire batch nor durability across a power failure.
Test consequences, not just the returned body
A successful request establishes little about scheduler behavior. Boundary scenarios matter: a server returns 503 and then recovers, an observer throws, a file appears during downloading, or preparation of the first job fails. Tests check outcomes, events and the absence of unwanted side effects.
The file-race regression creates the target after sink preparation and checks that its contents survive. Retry checks distinguish attempt errors from final failures. The waiting check records repeated sleeping when no requests are active. A separate HTTP fixture capable of serving multiple connections tests concurrent execution.
Flow checks cover inherited context, child resolver precedence, preservation of the base plan, nested stopping and original exception causes. PHPStan level 8 and executable examples complement these checks; they do not replace execution-semantics tests.
When these tools earn their place
Chain is useful when a fluent wrapper clarifies object interactions and the team accepts its dynamic semantics. For a few direct calls, ordinary PHP may be easier to read. Flow suits recurring application-step sequences needing composition, context and an explicit execution outcome.
Batch covers a bounded scope of concurrent HTTP work in one process: fetch responses, download files, retry eligible failures and inspect partial results. It has no persisted execution across restarts or distributed scheduling. Those requirements call for another execution layer.
All three utilities ship in one Composer package: composer require tommyknocker/chain. Consequently ext-curl is required at installation even if an application only uses Flow. The psr/container interface package is also required even when no resolver is configured. Separate execution models do not mean separate packages. The source and tests below are pinned to a specific implementation snapshot.