diff --git a/README.md b/README.md
index bbf988c69..46167e812 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,15 @@ $response = $operatorClient->DeleteNamespace(
> that change the behavior of the entire PHP worker.
> It's recommended to disable deprecated behavior.
+#### Update headers
+
+Client interceptors can supply update headers with `Temporal\Interceptor\Header::fromValues()`.
+For updates and the update portion of `updateWithStart()`, PHP header values use the client's
+configured data converter unless the header already has an explicitly supplied converter.
+Prebuilt `Payload` entries supplied through `Header::fromPayloadCollection()` or `withValue()`
+are forwarded unchanged, even when the same header also contains PHP values. Prepare those
+payloads with any application-specific encoding they require; they are not decoded or re-encoded.
+
## Testing
The PHP SDK includes a toolkit for testing Workflows.
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 57d72366a..ad7cd2897 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -374,9 +374,6 @@
-
-
-
@@ -411,9 +408,6 @@
-
-
-
diff --git a/src/Internal/Client/WorkflowStarter.php b/src/Internal/Client/WorkflowStarter.php
index 7e08e4a3b..0b29ff53d 100644
--- a/src/Internal/Client/WorkflowStarter.php
+++ b/src/Internal/Client/WorkflowStarter.php
@@ -175,7 +175,12 @@ function (UpdateWithStartInput $input): UpdateWithStartOutput {
$i = new \Temporal\Api\Update\V1\Input();
$i->setName($input->updateInput->updateName);
$input->updateInput->arguments->isEmpty() or $i->setArgs($input->updateInput->arguments->toPayloads());
- $input->updateInput->header->isEmpty() or $i->setHeader($input->updateInput->header->toHeader());
+ $header = $input->updateInput->header;
+ \assert($header instanceof Header);
+ if ($header->getDataConverter() === null) {
+ $header->setDataConverter($this->converter);
+ }
+ $header->isEmpty() or $i->setHeader($header->toHeader());
$r->setInput($i);
$ops = [
diff --git a/src/Internal/Client/WorkflowStub.php b/src/Internal/Client/WorkflowStub.php
index fb4d7e259..aaf70edf8 100644
--- a/src/Internal/Client/WorkflowStub.php
+++ b/src/Internal/Client/WorkflowStub.php
@@ -314,7 +314,12 @@ static function (
$input->arguments->setDataConverter($converter);
$input->arguments->setSerializationContext($context);
$input->arguments->isEmpty() or $i->setArgs($input->arguments->toPayloads());
- $input->header->isEmpty() or $i->setHeader($input->header->toHeader());
+ $header = $input->header;
+ \assert($header instanceof Header);
+ if ($header->getDataConverter() === null) {
+ $header->setDataConverter($converter);
+ }
+ $header->isEmpty() or $i->setHeader($header->toHeader());
$r->setInput($i);
try {
diff --git a/tests/Unit/Client/UpdateHeaderTestCase.php b/tests/Unit/Client/UpdateHeaderTestCase.php
new file mode 100644
index 000000000..b4d581561
--- /dev/null
+++ b/tests/Unit/Client/UpdateHeaderTestCase.php
@@ -0,0 +1,118 @@
+
+ */
+ public static function headers(): iterable
+ {
+ yield 'update, client converter' => [false, false];
+ yield 'update with start, client converter' => [true, false];
+ yield 'update, explicit converter' => [false, true];
+ yield 'update with start, explicit converter' => [true, true];
+ }
+
+ #[DataProvider('headers')]
+ public function testHeaderConversion(bool $withStart, bool $explicitConverter): void
+ {
+ $converter = $this->createMock(DataConverterInterface::class);
+ $headerConverter = $explicitConverter ? $this->createMock(DataConverterInterface::class) : $converter;
+ $encoded = new Payload(['metadata' => ['encoding' => 'custom/header'], 'data' => 'converted']);
+ $raw = new Payload(['metadata' => ['encoding' => 'custom/raw'], 'data' => "\x00raw\xff"]);
+ $rawBytes = $raw->serializeToString();
+
+ $headerConverter->expects(self::once())->method('toPayload')->with('value')->willReturn($encoded);
+ $headerConverter->expects(self::never())->method('fromPayload');
+ if ($explicitConverter) {
+ $converter->expects(self::never())->method('toPayload');
+ $converter->expects(self::never())->method('fromPayload');
+ }
+ $header = $explicitConverter
+ ? Header::fromPayloadCollection(['raw' => $raw], $headerConverter)->withValue('value', 'value')
+ : Header::fromValues(['value' => 'value'])->withValue('raw', $raw);
+
+ $interceptor = $this->createMock(WorkflowClientCallsInterceptor::class);
+ $interceptor->method('update')->willReturnCallback(
+ static fn(UpdateInput $input, callable $next) => $next($input->with(header: $header)),
+ );
+ $interceptor->method('updateWithStart')->willReturnCallback(
+ static fn(UpdateWithStartInput $input, callable $next) => $next(
+ $input->with(updateInput: $input->updateInput->with(header: $header)),
+ ),
+ );
+
+ $service = $this->createMock(ServiceClientInterface::class);
+ $service->method('getContext')->willReturn(Context::default());
+ $service->method('withContext')->willReturnSelf();
+ $response = (new UpdateWorkflowExecutionResponse())->setUpdateRef(
+ (new UpdateRef())->setUpdateId('update-id'),
+ );
+ $checkHeader = static function (UpdateWorkflowExecutionRequest $request) use ($encoded, $rawBytes): void {
+ $fields = $request->getRequest()->getInput()->getHeader()->getFields();
+ self::assertCount(2, $fields);
+ self::assertSame($encoded->serializeToString(), $fields['value']->serializeToString());
+ self::assertSame($rawBytes, $fields['raw']->serializeToString());
+ };
+ if ($withStart) {
+ $service->expects(self::once())->method('ExecuteMultiOperation')->willReturnCallback(
+ static function (ExecuteMultiOperationRequest $request) use ($checkHeader, $response) {
+ $checkHeader($request->getOperations()[1]->getUpdateWorkflow());
+
+ return (new ExecuteMultiOperationResponse())->setResponses([
+ (new Response())->setStartWorkflow(
+ (new StartWorkflowExecutionResponse())->setRunId('run-id'),
+ ),
+ (new Response())->setUpdateWorkflow($response),
+ ]);
+ },
+ );
+ } else {
+ $service->expects(self::once())->method('UpdateWorkflowExecution')->willReturnCallback(
+ static function (UpdateWorkflowExecutionRequest $request) use ($checkHeader, $response) {
+ $checkHeader($request);
+
+ return $response;
+ },
+ );
+ }
+ $client = new WorkflowClient(
+ $service,
+ converter: $converter,
+ interceptorProvider: new SimplePipelineProvider([$interceptor]),
+ );
+
+ if ($withStart) {
+ $client->updateWithStart($client->newUntypedWorkflowStub('workflow'), 'update');
+ } else {
+ $client->newUntypedRunningWorkflowStub('workflow-id')->startUpdate('update');
+ }
+
+ self::assertSame($headerConverter, $header->getDataConverter());
+ self::assertSame($rawBytes, $raw->serializeToString());
+ }
+}