A2A (Agent2Agent) is the open protocol, now hosted by the Linux Foundation, that lets agents built on different frameworks call each other. Version 1.0.0 was released on 12 March 2026. The official SDKs cover Python, JavaScript, Java, Go, .NET and Rust. PHP is not on that list, and the PHP packages I found on Packagist in September 2026 target protocol 0.3.0.
So I wrote a2a-php-sdk and a2a-symfony-bundle. A protocol SDK is only worth something if it talks to implementations written by other people, so this article is not about the SDK’s internals. It is about one reproducible run against the reference implementation:
| Direction | Client | Server |
|---|---|---|
| PHP → Python | a2a-php-sdk | the official hello-world sample on a2a-sdk |
| Python → Symfony | the official a2a-sdk client | a Symfony 8 application with the bundle |
Everything below comes from a2a-php-interop-demo. make demo builds it, runs both directions and exits 0. The full output of the run is committed as docs/run-2026-09-15.log.
The versions
| Component | Version |
|---|---|
| PHP | 8.4.23 |
| vbcherepanov/a2a-php-sdk | 1.0.0 |
| vbcherepanov/a2a-symfony-bundle | 1.0.2 |
| symfony/framework-bundle | 8.0.15 |
| Python | 3.13.15 |
| a2a-sdk | 1.1.0 |
| a2a-samples | commit 6603ba3 |
The Python agent is not a copy. The Dockerfile downloads __main__.py, agent_executor.py and requirements.txt from that exact a2a-samples commit and runs them unmodified.
One constraint shaped the whole setup
The official sample binds to 127.0.0.1:9999 and writes the same address into its Agent Card. In Docker that address is private to the container. Instead of patching the reference implementation, every service in compose.yaml joins the Python agent’s network namespace:
symfony-agent:
build: ./symfony-agent
network_mode: "service:python-agent"
Now 127.0.0.1:9999 means the Python agent and 127.0.0.1:8000 means the Symfony agent, for every container.
Direction 1: PHP calls the official Python agent
The client discovers the agent, picks the JSON-RPC interface from the card, and sends the same message twice — once blocking, once streaming:
$http = HttpClient::create();
$options = new CallOptions(timeoutSeconds: 30.0);
$card = (new Discovery($http, allowPrivateNetwork: true))->discover($baseUrl, $options);
$client = new Client(new HttpTransport($http, $endpoint, jsonRpc: true), $options);
echo $client->sendMessage($request())->serializeToJsonString();
foreach ($client->sendStreamingMessage($request()) as $event) {
echo $event->serializeToJsonString(), "\n";
}
allowPrivateNetwork: true is deliberate. By default the SDK’s discovery refuses private-network addresses, because an Agent Card is data from someone else. For a local demo that protection has to be switched off explicitly — and it stays on in production code.
The streaming call produced four events, in order:
task TASK_STATE_SUBMITTED
statusUpdate TASK_STATE_WORKING "Processing request..."
artifactUpdate "Hello, World! I have received your request (Hi from a2a-php-sdk)"
statusUpdate TASK_STATE_COMPLETED "Request is completed!"
Those strings come from the Python sample’s executor. The PHP side decoded every event into the generated protobuf classes without a single custom mapping.
Direction 2: the official Python client calls Symfony
On the Symfony side the agent is four files on top of symfony/skeleton and composer require vbcherepanov/a2a-symfony-bundle.
The executor — one class that yields an artifact and a final status:
final class EchoExecutor implements Executor
{
public function execute(SendMessageRequest $request, Task $task, CallContext $context): iterable
{
// collect the text parts of the incoming message into $text
yield (new StreamResponse())->setArtifactUpdate(/* "Hello from a Symfony agent. You said: $text" */);
yield (new StreamResponse())->setStatusUpdate(/* TASK_STATE_COMPLETED */);
}
}
The bundle config and the route import:
# config/packages/a2a.yaml
a2a:
executor: App\Agent\EchoExecutor
card_file: '%kernel.project_dir%/config/agent-card.json'
public_url: '%env(A2A_PUBLIC_URL)%'
auth:
tokens:
python-client: '%env(A2A_DEMO_TOKEN)%'
# config/routes/a2a.yaml
a2a:
resource: .
type: a2a
The Agent Card carries only identity, capabilities and skills. The bundle fills in the interfaces from public_url and the enabled transports.
The Python client is the official a2a-sdk API, with the bearer token passed through httpx:
async with httpx.AsyncClient(headers={'Authorization': f'Bearer {TOKEN}'}) as http:
client = await create_client(
agent=card,
client_config=ClientConfig(streaming=True, httpx_client=http),
)
async for chunk in client.send_message(request):
print(chunk)
This is what the official client printed about the Symfony agent and its reply:
Name : Symfony Echo Agent
[0] http://127.0.0.1:8000/a2a/rpc (JSONRPC 1.0)
[1] http://127.0.0.1:8000/a2a (HTTP+JSON 1.0)
Streaming : True
artifact_update "Hello from a Symfony agent. You said: Hi from the official A2A Python SDK"
status_update TASK_STATE_COMPLETED
Then the same client ran with a wrong token:
a2a.client.errors.A2AClientError: HTTP Error 401: Client error '401 Unauthorized'
for url 'http://127.0.0.1:8000/a2a/rpc'
The bundle has no anonymous mode. An A2A endpoint executes work on behalf of the caller, so the container refuses to compile unless you configure a token map, a Symfony access-token handler or your own authenticator.
Two bugs that only a real install could find
Preparing this demo, I installed the bundle the way a stranger would — composer require into a fresh symfony/skeleton — and it broke twice.
1.0.0: Symfony Flex registers a bundle automatically, even without a recipe, and then runs cache:clear. The bundle’s configuration marked card_file, executor and public_url as required, so the install failed with The child config “card_file” under “a2a” must be configured before anyone had a chance to configure anything. The bundle’s CI already installed the package into a fresh project, but that check did not go through Symfony Flex. Fixed in 1.0.1: without an a2a section the bundle registers nothing, and CI now installs through the skeleton.
1.0.1: with config/routes/a2a.yaml present but no configuration yet, routing failed with Cannot load resource ”.” Make sure there is a loader supporting the “a2a” type. That matters because a Flex recipe drops the routes file in at install time. Fixed in 1.0.2: the route loader is always registered and returns an empty collection until the bundle is configured.
Neither bug was visible to unit tests. Both were visible within a minute of installing the package the way the documentation tells users to.
What this run proves, and what it does not
It proves that, for the versions above, the PHP SDK and the Symfony bundle interoperate with the reference Python implementation over JSON-RPC, for Agent Card discovery, blocking SendMessage and streaming SendMessage, including bearer authentication on the Symfony side.
It does not cover the REST and gRPC bindings, push notifications or the extended Agent Card against Python — the SDK has its own tests for those, but not in this demo. The PHP built-in server is for the demo only.
Conformance is a separate question, answered by the official test kit rather than by one sample. The SDK runs the official a2a-tck 1.0.0 against all three transports: 244 passed, 3 failed, 18 skipped. The three failures are one requirement, CORE-SEND-003, which scores the correct error response as a failure because its definition omits the expected error; that is tracked as a2a-tck#202, and with the fix from PR #203 the run is 247 passed, 18 skipped. That is not a certification, and I don’t present it as one.
Try it
git clone https://github.com/vbcherepanov/a2a-php-interop-demo.git
cd a2a-php-interop-demo
make demo
make down
- SDK: github.com/vbcherepanov/a2a-php-sdk · Packagist
- Symfony bundle: github.com/vbcherepanov/a2a-symfony-bundle · Packagist
- Protocol: a2a-protocol.org
If it fails on your machine, open an issue in the demo repository with the output of make demo — a failing interop run is exactly the kind of report these packages need.