Skip to content

v2.18

Choose a tag to compare

@xepozz xepozz released this 18 Aug 06:53
· 11 commits to master since this release
6e81416

Temporal PHP SDK v2.18

This release focuses on extensibility, broader client coverage, and a stronger testing story, along with a set of correctness fixes and dependency updates.


Features

Plugin System

PR: #724
Author: @xepozz

A new plugin architecture lets you hook into the SDK lifecycle — connection, client, schedule client, worker and worker factory. Each stage has its own interface with a no-op trait, so a plugin implements only the methods it cares about by extending AbstractPlugin.

use Temporal\Plugin\AbstractPlugin;
use Temporal\Plugin\WorkerPluginContext;

final class MetricsPlugin extends AbstractPlugin
{
    public function __construct()
    {
        parent::__construct(name: 'metrics');
    }

    // override only the hooks you need; the rest are no-ops.
    // each hook is middleware — call $next to continue the chain.
    public function configureWorker(WorkerPluginContext $context, callable $next): void
    {
        $context->addInterceptor(new MyInterceptor());
        $next($context);
    }
}

Plugins are registered by name in a PluginRegistry (duplicate names raise a \RuntimeException); WorkflowClient::create() accepts a registry and worker plugins registered on the client are auto-propagated to workers.

Resolves [#644](#644).


Operator / Service Client

PR: #737
Author: @shanginn

A repo-canonical OperatorClient / OperatorClientInterface exposes Temporal operator RPCs — including DeleteNamespace — through the SDK's own client abstraction. The shared gRPC plumbing (connection handling, metadata, interceptors, retries, call context) was generalized so workflow and operator clients behave identically.

use Temporal\Api\Operatorservice\V1\DeleteNamespaceRequest;
use Temporal\Client\GRPC\OperatorClient;

$operatorClient = OperatorClient::create('127.0.0.1:7233');
$response = $operatorClient->DeleteNamespace(
    (new DeleteNamespaceRequest())->setNamespace('example-namespace'),
);

Resolves [#400](#400).


Activity Reset Support

PR: #665
Author: @roxblnfk

Activity::heartbeat() now throws a new ActivityResetException when the server reports the attempt was reset (alongside cancelled/paused), and the reset state is exposed via ActivityCancellationDetails::$reset.

use Temporal\Activity;
use Temporal\Exception\Client\ActivityResetException;

try {
    Activity::heartbeat($progress);
} catch (ActivityResetException) {
    // attempt was reset by the user
}

// or inspect without throwing:
if (Activity::getCancellationDetails()?->reset) {
    // ...
}

Resolves [#605](#605).


Expose Side Effect Summary

PR: #700
Author: @xepozz

Workflow::sideEffect() now accepts an optional SideEffectOptions carrying a single-line summary (surfaced in the Web UI / CLI).

use Temporal\Common\SideEffectOptions;

$id = yield Workflow::sideEffect(
    fn() => Uuid::v4(),
    SideEffectOptions::new()->withSummary('generate correlation id'),
);

The summary field is currently marked experimental.


Expose Summary Metadata for Local Activities

PR: #655
Author: @roxblnfk

Local activities gain the same summary-metadata support as regular activities via LocalActivityOptions::withSummary().

use Temporal\Activity\LocalActivityOptions;

$stub = Workflow::newActivityStub(
    Billing::class,
    LocalActivityOptions::new()
        ->withStartToCloseTimeout(5)
        ->withSummary('charge customer'),
);

Typed Results & firstExecutionRunId for Update-with-Start

PR: #761
Author: @xepozz

On the updateWithStart caller path, the resultType and firstExecutionRunId from UpdateOptions are now propagated to the server request (previously hardcoded), so the update result can be typed instead of stdClass.

use Temporal\Client\Update\UpdateOptions;
use Temporal\Client\Update\LifecycleStage;

$options = UpdateOptions::new('echo', LifecycleStage::StageCompleted)
    ->withResultType(UpdateResult::class);

$handle = $client->updateWithStart($stub, $options, ['hello']);
$result = $handle->getResult();   // instance of UpdateResult

Testing Framework

A dedicated set of changes brings the PHP testing framework close to Go/Java/TS: outbound calls can be stubbed, time-driven workflows run under time-skipping, and history can be asserted after a run.

Mock Activities & Child Workflows

PR: #778
Author: @xepozz

Child workflows were the last outbound call with no way to stub them, and activity mocks couldn't vary the result per call.

// Stub a child workflow — no real child execution is started
$this->workflowMocks->expectCompletion('GreetChild', 'Hello, Antony');
$this->workflowMocks->expectFailure('GreetChild', new \RuntimeException('boom'));
$this->workflowMocks->expectCompletionWhen('GreetChild', ['Antony'], 'Hi');

// Per-call activity results inside a loop
$this->activityMocks->expectConsecutiveCompletions('Order.charge', [true, false, true]);
// Arg-matched activity result
$this->activityMocks->expectCompletionWhen('Order.charge', [100], true);

// getVersion / sideEffect mocks
$this->workflowMocks->expectVersion('migrate-db', 2);
$this->workflowMocks->expectSideEffect('deterministic-value');

History-based assertions over a finished run via WorkflowInteractions ($this->interactions($run)):

$this->interactions($run)->activity('Order.charge')->withInput(100)->assertCalledOnce();
$this->interactions($run)->childWorkflow('GreetChild')->assertStartedTimes(3);
$this->interactions($run)->timer()->assertStarted('PT30M');
$this->interactions($run)->signal('approve')->assertNeverSent();
$this->interactions($run)->assertNoOtherActivities();

Closes [#524](#524), [#302](#302).


Time-Skipping Support & Delayed-Callback Scheduler

PR: #779
Author: @xepozz

Timer-driven workflows are now testable under time-skipping (composer test:func-timeskip), and a client-side scheduler fires signals/queries/cancels at simulated-time offsets — so a 30-minute-timer workflow completes in seconds of wall-clock.

final class TimerTest extends TimeSkippingWorkflowTestCase
{
    public function testDelayedSignals(): void
    {
        $stub = $this->workflowClient->newUntypedWorkflowStub('SignalCollectorWorkflow');

        $this->delayedCallbacks
            ->signalAfter(60, 'add', 'a')
            ->signalAfter(120, 'add', 'b')
            ->start($stub);

        $this->assertSame(['a', 'b'], $stub->getResult());
    }
}
  • TimeLockingInterceptor + TimeSkippingWorkflowTestCase — timer-only workflows and ActivityMocker now run under time-skipping.
  • Lock-counter ownership guard in WorkflowTestCase::tearDown so a leaked lock no longer poisons the suite.

Closes [#743](#743), [#745](#745), [#529](#529), [#744](#744).


Mock Typed Search-Attribute Upsert

PR: #784
Author: @xepozz

Workflows that upsert typed search attributes can now run against the time-skipping test server (which doesn't implement that RPC), and the upserts are assertable.

$this->searchAttributeMocks->assertUpserted('customKeywordField');
$this->searchAttributeMocks->assertUpsertedValue('customIntField', 42);
$this->searchAttributeMocks->assertUnset('obsoleteField');

Closes [#654](#654).


PHPUnit Filtering in the Test Worker

PR: #748
Author: @xepozz

Acceptance bootstrapping moved into a PHPUnit Extension that collects the test classes PHPUnit actually selected and passes them to the RoadRunner worker (test-class= arg), so only the needed task queues are created instead of one per test. A filtered run no longer pays for every feature's queue:

composer test:accept -- --filter 'UpdateWorkflowTestCase'

Transcript Debug Module

PR: #754
Author: @xepozz

A Temporal\Testing\Transcript module records inbound/outbound wire frames, logs, raised errors and workflow-history-on-failure to per-run files while debugging. It ships as a worker plugin (TranscriptPlugin, built on the new plugin system) and adds composer scripts to view/merge/clean output; location is controlled by TEMPORAL_TRANSCRIPT_* env vars.

TEMPORAL_TRANSCRIPT_DUMP_ON_FAIL=1 composer test:accept
composer transcripts:merge

Improvements & Fixes

Handle CanceledException as TimeoutException

PR: #709
Author: @xepozz

RPC cancellation during update/result waits is now surfaced consistently as a timeout-or-canceled condition, aligned with the Go, Java and TypeScript SDKs.

Propagate Cancellation to Scopes & Awaits Registered After Cancel

PR: #770
Author: @xepozz

Cancellation now propagates to scopes and awaits that are registered after a cancel has already fired (closes [#769](#769)). Gated behind a feature flag (default off):

\Temporal\Worker\FeatureFlags::$propagateCancellationToNewScopes = true;

Generate Update ID Client-Side When Not Provided

PR: #773
Author: @xepozz

When the caller supplies no update ID, the client now generates a UUID v4 (Common\Uuid::v4()) instead of sending an empty string — fixing updates that never resolved on the time-skipping test server, and matching Go/Java/TS.

Closes [#577](#577).

Merge Method-Level #[MethodRetry] into Retry Options

PR: #783
Author: @xepozz

A method-level #[MethodRetry] is now merged with (rather than overridden by) options passed at call time — options set explicitly at the call site win, and the rest fall back to the attribute.

#[ActivityMethod]
#[MethodRetry(maximumAttempts: 5, initialInterval: '1s')]
public function charge(int $amount): void;

// call-site options merge on top of the attribute:
// maximumAttempts stays 5, initialInterval overridden to 2s
$activity = Workflow::newActivityStub(
    Billing::class,
    ActivityOptions::new()
        ->withRetryOptions(RetryOptions::new()->withInitialInterval('2s')),
);

Others improvements and fixes


New Contributors


Full Changelog

v2.17.1...v2.18