temporal/sdk
PHP SDK for authoring Temporal Workflows and Activities, enabling scalable, durable async business logic. Clients require ext-grpc; Workers require RoadRunner, with ext-protobuf recommended for production performance. Install via Composer.
Install: composer require temporal/sdk
Match extensions to role — the SDK splits into two halves with different requirements:
| Component | Needs | Recommend |
|---|---|---|
| Client (start/signal/query) | ext-grpc |
ext-protobuf |
| Worker (executes code) | RoadRunner binary | ext-protobuf |
./vendor/bin/rr get downloads RoadRunner. A client-only app (a Laravel controller dispatching workflows) does not need RoadRunner; a worker app does not need ext-grpc.
Run a local server: temporal server start-dev --log-level error gives you a full server + Web UI + SQLite on 127.0.0.1:7233. No Docker required.
Where to look first, in order: the samples-php repo (working end-to-end code beats docs), then docs.temporal.io/develop/php, then php.temporal.io for the API surface. The SDK's own README is a setup guide, not a tutorial — don't expect to learn the programming model from it.
First real use case: replace a cron job or queued chain that has become un-debuggable — e.g. "reserve stock → charge card → send receipt" with retries, sleeps, and a compensating refund. That exercises the three things Temporal is actually for: durable retries, durable timers, and visible execution history.
Minimal shape of that first workflow:
#[WorkflowInterface]
interface OrderWorkflow
{
#[WorkflowMethod]
public function run(string $orderId): string;
}
Workflow/Activity split is the whole design. Workflows must be deterministic; anything touching the network, disk, clock, or randomness belongs in an Activity. A practical rule: if you'd mock it in a unit test, it's an Activity.
Activity stubs, not Activity classes, inside workflows. Workflows depend on the interface; the worker wires the implementation:
$charge = Workflow::newActivityStub(
PaymentActivity::class,
ActivityOptions::new()
->withStartToCloseTimeout('30 seconds')
->withRetryPolicy(RetryPolicy::new()
->withMaximumAttempts(5)
->withNonRetryableErrorTypes([CardDeclined::class])),
);
$receipt = yield $charge->charge($orderId, $amount);
Set startToCloseTimeout on every activity stub — it is the one option the SDK cannot sensibly default, and a missing timeout surfaces late as a stuck workflow rather than an error.
Orchestration with promises. PHP workflows are generators; yield awaits. Fan out and join with Promise::all / Promise::any rather than sequential awaits when the calls are independent. In v2.19+, FeatureFlags::$settleAwaitOnFirstSettledCondition = true makes multi-condition await()/awaitWithTimeout() unblock on the first settled promise (rejection propagates) instead of waiting out the timer.
Signals, queries, updates for external input. Long-running workflows typically yield Workflow::await(...) on a signal flag or an activity promise, with Workflow::awaitWithTimeout() for the "wait up to N days, then escalate" shape. Updates (updateWithStart) need server-side flags on dev clusters — see the README's --dynamic-config-value list.
Client side in a framework app. Keep a single long-lived WorkflowClient (bound in a service provider / container singleton) — it wraps a gRPC connection, so constructing one per request is wasteful. Use start() when you need the result inline, startAsync() + getResult() when you don't, signalWithStart() when the workflow may or may not already exist, and Schedules for true cron replacements.
Worker as a separate process. Boot via WorkerFactory + RoadRunner, register workflow implementation types and activity implementations, and point it at the same task queue the client uses:
$factory = WorkerFactory::create();
$worker = $factory->newWorker('orders', WorkerOptions::new()->withMaxConcurrentActivityTaskPollers(4));
$worker->registerWorkflowImplementationTypes([OrderWorkflowImpl::class]);
$worker->registerActivityImplementations([$container->get(PaymentActivity::class)]);
$factory->run();
Keep the worker in its own deployable, scaled independently of the web app. This is the main architectural change when adopting the package.
Testing is a first-class workflow, use it. TestWorkflowEnvironment + WorkflowStub lets you mock activities and run the whole workflow in-process without a server; the README also points at the Replay API for CI, which re-executes stored histories against new code to catch nondeterminism before you deploy. Wire both into CI — replay catching a determinism break is worth more than any unit test.
For Spiral users, the framework integration (spiral/temporal-builder) removes most of the worker boilerplate. For Laravel/Symfony there's no official bridge as of this writing; you'll write a thin service provider and an rr config file yourself.
var_dump, echo, or print_r in a worker. STDOUT is swallowed by RoadRunner. Install Buggregator with trap()/dump() — Trap even patches var-dumper to render protobuf messages readably, which matters constantly here. Teams that skip this lose days to blind debugging.time(), rand(), date(), file_get_contents(), new DateTime(), and $this-mutation ordering inside a workflow can all break replay weeks later. Use Workflow::now(), Workflow::sideEffect(), and move everything else into activities. Turn on the Replay API tests early, not after the first incident.yield is not optional and is easy to forget. A missing yield returns the promise object itself; the workflow completes "successfully" with the wrong value and only replay/tests catch it. Code review for $x = $activity->foo() without yield in workflow classes.Workflow::getVersion() (patching) is the supported way to alter a running workflow's code path; without it, in-flight executions fail or diverge on the next worker deploy. Note that getVersion calls themselves become part of the history and must be preserved.Temporal\Worker\FeatureFlags statics (e.g. $settleAwaitOnFirstSettledCondition in v2.19). The README explicitly recommends disabling deprecated behavior — but do it deliberately, in a release where you've replayed histories, since flipping a flag changes the commands emitted.maximumAttempts or maximumInterval, and mark business-logic failures non-retryable via withNonRetryableErrorTypes or by throwing ApplicationFailure.Running, and no worker picks it up. First debugging step for any stalled execution: confirm client and worker agree on both task queue and namespace.Activity::heartbeat() plus a heartbeatTimeout, a crashed worker leaves the activity to time out only at startToCloseTimeout — potentially an hour of dead time instead of seconds.OperatorClient::create(...) for namespace/operator work and CloudClient::createSSL(...)->withAuthKey(...) (v2.19+) for Temporal Cloud; they share connection handling with WorkflowClient but are not the same object. withApiVersion() pins the required temporal-cloud-api-version header, and a version already present on the call context wins over it.roadrunner-api-dto constraint moves with releases (v2.19 requires ^1.15.0); a stale lockfile alongside a newer temporal/sdk is a common source of confusing gRPC stub mismatches. Bump both together.How can I help you explore Laravel packages today?