Skip to content

v2.20

Latest

Choose a tag to compare

@xepozz xepozz released this 23 Oct 12:54
5180fcd

Temporal PHP SDK v2.20.0

Warning

PHP 8.2+ is required. Support for Carbon 2 and Symfony 5.4 is dropped.
RoadRunner PHP packages moved from spiral/roadrunner-* to roadrunner/*, and RoadRunner v3 is recommended.

This release adds serialization context for data converters, RoadRunner v3 support, payload size warnings, and a guard that prevents read-only workflow callbacks from creating commands.


Features

Serialization Context

PR: #796
Author: @xepozz

A payload converter can now know which Workflow or Activity it serializes data for — for example, to pick an encryption key or sign payloads per Workflow.
Implement SerializationContextAwareInterface on your converter: DataConverter passes the context down to every converter that implements it.

Two context types are provided:

  • WorkflowSerializationContext — namespace, workflowId;
  • ActivitySerializationContext — namespace, activityType, taskQueue, workflowId, workflowType, isLocal.

Both implement HasWorkflowSerializationContext, so getNamespace() and getWorkflowId() work for either.

The example below signs each payload with the Workflow it belongs to, and rejects a payload decoded in a different context:

use Temporal\Api\Common\V1\Payload;
use Temporal\DataConverter\HasWorkflowSerializationContext;
use Temporal\DataConverter\PayloadConverterInterface;
use Temporal\DataConverter\SerializationContext;
use Temporal\DataConverter\SerializationContextAwareInterface;
use Temporal\DataConverter\Type;

final class SignedValue
{
    public function __construct(public mixed $value) {}
}

final class SigningConverter implements PayloadConverterInterface, SerializationContextAwareInterface
{
    private const ENCODING = 'binary/signed';
    private const METADATA_KEY = 'ctx-signature';

    private ?SerializationContext $context = null;
    private string $signature = 'none';

    public function getEncodingType(): string
    {
        return self::ENCODING;
    }

    public function getSerializationContext(): ?SerializationContext
    {
        return $this->context;
    }

    public function withSerializationContext(?SerializationContext $context): static
    {
        $clone = clone $this;
        $clone->context = $context;
        $clone->signature = $context instanceof HasWorkflowSerializationContext
            ? \sprintf('%s|%s', $context->getNamespace(), $context->getWorkflowId() ?? '')
            : 'none';
        return $clone;
    }

    public function toPayload($value): ?Payload
    {
        if (!$value instanceof SignedValue) {
            return null;
        }

        return (new Payload())
            ->setData(\json_encode($value->value))
            ->setMetadata(['encoding' => self::ENCODING, self::METADATA_KEY => $this->signature]);
    }

    public function fromPayload(Payload $payload, Type $type): mixed
    {
        $signature = $payload->getMetadata()[self::METADATA_KEY] ?? '';
        if ($signature !== $this->signature) {
            throw new \RuntimeException("Payload signed for \"$signature\", decoded in \"{$this->signature}\"");
        }

        return new SignedValue(\json_decode($payload->getData(), false, 512, \JSON_THROW_ON_ERROR));
    }
}

Register the converter on both the client and the worker, before JsonConverter:

use Temporal\DataConverter\{BinaryConverter, DataConverter, JsonConverter, NullConverter, ProtoConverter, ProtoJsonConverter};

$converter = new DataConverter(
    new SigningConverter(),
    new NullConverter(),
    new BinaryConverter(),
    new ProtoJsonConverter(),
    new ProtoConverter(),
    new JsonConverter(),
);

$client = WorkflowClient::create($serviceClient, converter: $converter);
$factory = WorkerFactory::create(converter: $converter);

The context is applied on both the client and the worker side: Workflow and Activity inputs and results, signals, queries, updates, memo, child Workflows, Continue-As-New, external signals and failures (FailureConverter).
ActivityCompletionClient applies no context by default; use withContext(ActivitySerializationContext $context) to set one.

The same behavior is covered by the cross-SDK conformance tests in temporalio/features.

Closes #587.


RoadRunner v3 Support

PR: #832
Author: @xepozz

The SDK now works with RoadRunner v3. The PHP dependencies moved to the new roadrunner/* package names:

Before After
spiral/roadrunner roadrunner/roadrunner
spiral/roadrunner-cli roadrunner/cli
spiral/roadrunner-kv roadrunner/kv
spiral/roadrunner-worker roadrunner/worker
roadrunner-php/roadrunner-api-dto roadrunner/api-dto
roadrunner-php/version-checker roadrunner/version-checker

If your project requires the old package names directly, update your composer.json.

The required RoadRunner version is now ^3.0, so rr get-binary downloads RoadRunner v3.
A RoadRunner 2025.x binary still runs the worker, but the worker logs a version warning at start, and the new worker options below have no effect on it.


Payload Size Warnings

PR: #811
Author: @xepozz

The client logs a [TMPRL1103] warning when an outgoing request carries payloads above a limit: 512 KiB for payloads and 2 KiB for memo by default.
The limits are configurable (experimental):

use Temporal\Client\ClientOptions;
use Temporal\Common\PayloadLimitOptions;

$options = (new ClientOptions())->withPayloadLimits(
    PayloadLimitOptions::new()->withPayloadSizeWarning(1024 * 1024),
);

// or turn the warnings off
$options = (new ClientOptions())->withPayloadLimits(PayloadLimitOptions::disabled());

Closes #802.


Disable the Payload Error Limit on the Worker

PR: #837
Author: @xepozz

By default, the worker fails the Workflow Task with PAYLOADS_TOO_LARGE when a payload exceeds the namespace limit. The new option turns this check off:

$worker = $factory->newWorker('task-queue', WorkerOptions::new()->withDisablePayloadErrorLimit());

Closes #801.


Configurable Eager Activity Reservation Limit

PR: #789
Author: @xepozz

The number of eager activities reserved per Workflow Task was hardcoded to 3. Now it is configurable (RoadRunner 3.0+):

WorkerOptions::new()->withMaxEagerActivityReservationsPerWorkflowTask(10);

To turn eager execution off, use withDisableEagerActivities().

Closes #795.


AwaitOptions for Workflow::awaitWithTimeout()

PR: #805
Author: @xepozz

Workflow::awaitWithTimeout() now accepts AwaitOptions as the first argument, so the underlying timer can get a summary. Concurrent awaits are now distinguishable in the UI.

use Temporal\Workflow\AwaitOptions;
use Temporal\Workflow\TimerOptions;

yield Workflow::awaitWithTimeout(
    AwaitOptions::new(30)->withTimerOptions(TimerOptions::new()->withSummary('wait-for-resolution')),
    fn(): bool => $this->resolution !== null,
);

Closes #803.


Read-Only Workflow Callbacks

PR: #810
Author: @xepozz

A query handler, an update validator, an await condition and a Workflow::sideEffect() callback could create commands (start a timer, an activity, etc.).
Such commands are not part of the workflow's sequence and break replay. Now such a call throws.

The guard is on by default. To keep the previous behavior:

\Temporal\Worker\FeatureFlags::$readOnlyWorkflowCallbacks = false;

Priority Is Stable

PR: #818
Author: @yuandrew

Priority and its API are no longer experimental.


Others improvements and fixes

  • #815 – chore: drop PHP 8.1 support (@xepozz)
  • #817 – chore: drop support of old dependency branches: Carbon 2, Symfony 5.4 (@xepozz)
  • #823 – fix(worker): WorkerOptions::$deploymentOptions defaults to null instead of an uninitialized property (@xepozz)
  • #829 – perf: native defaults and time-only DateInterval without Carbon (@xepozz)
  • #828 – chore(bench): PHPBench microbenchmarks and a PR job that compares them with the base branch (@xepozz)
  • #814 – chore: bump internal/dload to ^1.16.5 (@xepozz)
  • #819 – ci: cache the DLoad version registry between workflow runs (@roxblnfk)
  • #836 – test: accept null for empty search attributes on Temporal Server 1.31+ (@xepozz)

New Contributors


Full Changelog

v2.19...v2.20.0