Python SDK → PHP mapping¶
Reference: a2aproject/a2a-python @ 0d5473c (2026-09-24). This is PyPI a2a-sdk, 118 test files, Apache-2.0. This table is the "same shape" contract: every public Python class gets a PHP class with the same name, in the same position in the tree. Method names change from snake_case to camelCase. That is the only rename.
Public API vs internals
Some classes below exist to mirror Python's internals and are marked @internal in PHP, for example EventConsumer, ActiveTaskRegistry, the SSE parser, HttpSenderFactory, ResponseHelpers, Jcs and every Compat\V0_3 class. They are not covered by the backward-compatibility promise and are left out of the API reference.
Rule for changes: when Python adds, renames or removes a public class, update this table first, then the code.
Package layout¶
Python (src/a2a/…) |
PHP (namespace A2A\…) |
Package | Notes |
|---|---|---|---|
types/a2a_pb2.py |
Types\* (generated) |
core | Generated from a2a.proto with protoc --php_out. php_namespace is set to A2A\Types through buf managed mode, so users don't see Lf\A2a\V1. |
types/a2a_pb2_grpc.py |
Types\Grpc\* (generated) |
grpc (later) | |
helpers/proto_helpers.py |
Helpers\ProtoHelpers |
core | newTaskFromUserMessage, text/data part builders, newAgentTextMessage, … |
helpers/agent_card.py |
Helpers\AgentCardHelpers |
core | |
utils/constants.py |
Utils\Constants |
core | Well-known path, header names, default versions. |
utils/errors.py |
Utils\Errors\*Error |
core | One exception class per A2A error, each carrying its JSON-RPC code + gRPC status. |
utils/error_handlers.py, grpc_status.py |
Utils\ErrorHandlers |
core | Exception → JSON-RPC / REST / gRPC error body. |
utils/proto_utils.py |
Utils\ProtoUtils |
core | ProtoJSON encode/decode, Struct ↔ PHP array; validateProtoRequiredFields(), warnOnMissingRequiredFields() (logs through a PSR-3 logger; Python also calls it from three logger-less helpers, which PHP does not). |
utils/json_utils.py |
Utils\JsonUtils |
core | A separate class, like Python. An empty PHP array means []; use new \stdClass() for {}. |
utils/task.py |
Utils\TaskUtils |
core | completedTask(), apply historyLength, page tokens: encodeListTasksCursor() / decodeListTasksCursor() (keyset cursor, a2a-sdk 1.2.1) and the legacy encodePageToken() / decodePageToken(). The cursor holds microseconds (us), not Python's nanoseconds (ts); tokens are opaque and only read by the store that issued them. |
utils/_jcs.py, signing.py |
Utils\Jcs, Utils\Signing (createAgentCardSigner(), createSignatureVerifier(), canonicalizeAgentCard(), cleanEmpty()), Utils\Signing\{SignatureVerificationError, NoSignatureError, InvalidSignaturesError} |
core (+ suggest: firebase/php-jwt, Python's signing extra) |
RFC 8785 canonicalization + card JWS. HMAC keys must be as long as the hash (RFC 7518). |
utils/push_url_validator.py |
Utils\PushUrlValidator |
core | SSRF guard: resolve DNS, reject private/loopback/link-local addresses. resolve() returns the approved addresses so the sender can pin them; allowedHosts exempts named hosts (development). |
utils/input_mode_validator.py (a2a-sdk 1.2.0, opt-in) |
not ported (yet) | Rejects a message whose parts' media types the card's input modes don't accept (ContentTypeNotSupportedError). Off by default in Python. |
|
utils/version_validator.py |
Utils\VersionValidator |
core | Reads A2A-Version. |
utils/telemetry.py |
Utils\Telemetry |
core (suggest: open-telemetry/api) |
No-op unless OTel is installed, same as Python. |
extensions/common.py |
Extensions\Common |
core | Parse A2A-Extensions, find extensions by URI. PHP adds activatableExtensions() and missingRequiredExtensions(), which the DefaultRequestHandler uses to enforce required extensions and activate + echo requested ones (Python leaves that to the app). |
auth/user.py |
Auth\User, Auth\UnauthenticatedUser |
core |
Server¶
| Python | PHP | Notes |
|---|---|---|
server/agent_execution/agent_executor.py AgentExecutor |
Server\AgentExecution\AgentExecutor (interface) |
execute(RequestContext, EventQueue): void, cancel(RequestContext, EventQueue): void. The one interface users write. |
…/context.py RequestContext |
Server\AgentExecution\RequestContext |
getUserInput(), taskId(), contextId(), message(), currentTask(), relatedTasks(), configuration(), callContext(), metadata(), tenant(), requestedExtensions(). PHP adds isCancelled() (cooperative cancellation, see CancellationToken) and activatedExtensions(), isExtensionActive(), activateExtension(). |
…/request_context_builder.py, simple_request_context_builder.py |
same names | |
…/active_task.py ActiveTask, EventConsumer |
Server\AgentExecution\ActiveTask, Server\AgentExecution\EventConsumer |
Python runs the executor as an asyncio task. PHP runs it in a Fiber: each enqueueEvent() suspends it, the EventConsumer checks, saves and publishes the event, then it resumes. TaskCancelledException stands in for asyncio.CancelledError. This is the biggest place PHP has to differ. See Running agents in PHP. |
…/active_task_registry.py |
Server\AgentExecution\ActiveTaskRegistry |
Builds a fresh ActiveTask per request: a PHP process usually serves one request, so what must be shared lives in the QueueManager. |
| — | Server\AgentExecution\TaskRunner, InlineTaskRunner |
PHP-only. Decides where execute() runs (inline in the request here; on a queue worker in the Laravel bridge), holds the per-task run lease, and runs work deferred until after the response. |
server/events/event_queue.py EventQueue |
Server\Events\EventQueue (interface) + InMemoryEventQueue |
enqueueEvent(Event). |
…/queue_manager.py QueueManager, InMemoryQueueManager |
same, plus PdoQueueManager |
Different shape: a per-task append-only event log readable from a sequence number, plus the cancel flag and run leases, so separate PHP processes can stream and cancel the same task. PdoQueueManager uses SQLite/PostgreSQL/MySQL; the Laravel bridge adds Redis. |
…/event_consumer.py, event_queue_v2.py |
not ported | Python's legacy consumer and its asyncio queue plumbing. The v2 consumer lives in active_task.py (see above). PublishedEvent is the PHP form of the (event, updated_task) pair Python queues for subscribers. |
server/tasks/task_store.py TaskStore |
Server\Tasks\TaskStore (interface) |
save / get / list / delete, each taking a ServerCallContext. |
…/inmemory_task_store.py, copying_task_store.py |
InMemoryTaskStore, CopyingTaskStore |
In-memory only lives as long as the PHP process: use it in tests and long-running servers, not under PHP-FPM. |
…/database_task_store.py (SQLAlchemy) |
Server\Tasks\PdoTaskStore |
core; plain PDO, supports pgsql/mysql/sqlite, same owner scoping, ordering and page tokens. The Laravel bridge runs it on the app's connection and ships the migration. |
…/task_manager.py, result_aggregator.py |
same | |
…/task_updater.py TaskUpdater |
Server\Tasks\TaskUpdater |
updateStatus, addArtifact, complete, failed, reject, submit, startWork, cancel, requiresInput, requiresAuth, newAgentMessage. |
…/push_notification_config_store.py + inmemory/database |
Server\Tasks\PushNotificationConfigStore + InMemoryPushNotificationConfigStore, PdoPushNotificationConfigStore, PushNotificationConfigs::normalize() (Python normalize_push_notification_config()) |
setInfo() still returns void: Python made it return the stored config in 1.2.2, but changing a PHP interface's return type would break custom stores within 1.x, so the handler returns normalize()'s copy instead (the same thing Python does for stores that return nothing). |
…/push_notification_sender.py, base_push_notification_sender.py |
Server\Tasks\PushNotificationSender, BasePushNotificationSender |
Sends through the client's HttpSender layer (Guzzle, Symfony or any PSR-18 client). Adds the Authorization header from the config's authentication, retries with backoff, a stable X-A2A-Notification-Id, address pinning and no redirects; SSRF screening is on by default. |
server/request_handlers/request_handler.py RequestHandler |
Server\RequestHandlers\RequestHandler (interface) |
onGetTask, onListTasks, onCancelTask, onMessageSend, onMessageSendStream (a Generator), onSubscribeToTask (a Generator; may yield null keep-alive ticks), on{Create,Get,List,Delete}TaskPushNotificationConfig, onGetExtendedAgentCard, and PHP's runBackgroundWork(). |
…/default_request_handler_v2.py DefaultRequestHandler(V2) |
Server\RequestHandlers\DefaultRequestHandler |
We port only V2. Python keeps the older LegacyRequestHandler around for its own compatibility; we have no old users to keep working, so we skip it. Differences are listed on Conformance. |
…/response_helpers.py |
Utils\ErrorHandlers, Server\Routes\Common |
Error envelopes come from ErrorHandlers; Common holds the JSON helpers. |
…/grpc_handler.py |
Server\RequestHandlers\GrpcHandler |
grpc package, later. |
server/context.py ServerCallContext |
Server\ServerCallContext |
user, state, requestedExtensions, tenant, plus activatedExtensions (echoed in the A2A-Extensions response header). |
server/owner_resolver.py, id_generator.py |
same | Owner = which user a task belongs to. This is how "not found" and "not allowed" stay the same. |
server/cluster/* (VersionedTaskStore, TaskEventStream, VersionedDatabaseTaskStore, DatabaseTaskEventStream; a2a-sdk 1.2.0) |
not ported (yet) | Python's opt-in multi-replica mode. PHP already shares task state across processes (PHP-FPM is many processes): the event log, any-process subscribe and cancel, and serialized follow-ups come from QueueManager (PdoQueueManager, Laravel's RedisQueueManager) and its run lease. Missing: versioned (optimistic) task writes, so a cancel that gives up waiting and writes the task itself can be overwritten by a still-running executor. |
server/routes/jsonrpc_dispatcher.py, rest_dispatcher.py |
Server\Routes\JsonRpcDispatcher, RestDispatcher |
PSR-15 RequestHandlerInterfaces. Any framework can mount them. |
server/routes/{jsonrpc,rest,agent_card}_routes.py create_*_routes() |
Server\Routes\Routes::jsonRpc(), ::rest(), ::agentCard(), ::router() |
PSR-15 handlers. AgentCardHandler adds the caching headers the spec recommends and takes a signer that signs a copy of the card once per process (Python has no signing hook on the route: you wrap the signer in your own async card_modifier). Router combines all three for plain-PHP front controllers. Both dispatchers echo activated extensions in A2A-Extensions. |
| — | Server\Routes\ResponseEmitter, ServerRequestFactory, Sse\SseStream |
PHP-only. Emit responses from plain PHP (flushing SSE per event, noticing disconnects, running background work after the response); build the PSR-7 request from globals; a PSR-7 body that streams SSE from a generator. |
server/routes/fastapi_routes.py add_a2a_routes_to_fastapi |
Laravel bridge: Route::a2a(...) macro (A2A\Laravel\Routing\A2ARoutes) |
The framework glue lives in the bridge package, not in core. |
server/routes/common.py ServerCallContextBuilder, DefaultServerCallContextBuilder |
same | Builds the context from the PSR-7 request: headers, A2A-Extensions, and the user your auth middleware stored in the a2a.user request attribute. |
Client¶
Namespace A2A\Client.
Python (src/a2a/client/…) |
PHP | Notes |
|---|---|---|
client.py Client, ClientConfig, ClientCallContext |
Client (abstract), ClientConfig, ClientCallContext |
sendMessage() / subscribe() return a Generator of StreamResponse, PHP's answer to Python's async iterator. ClientConfig::$httpClient replaces httpx_client and takes Guzzle, Symfony HttpClient, any PSR-18 client or an HttpSender. No grpc_channel_factory yet. |
base_client.py BaseClient |
BaseClient |
Same config handling and interceptor semantics; agentCard() exposes the current card. |
client_factory.py ClientFactory, create_client(), minimal_agent_card() |
ClientFactory, ClientFactory::createClient(), ClientFactory::minimalAgentCard() |
PHP can't have a static and an instance method both named create(), so Python's module-level create_client() is the static createClient(). Instance create() / createFromUrl() / register() as in Python. A v0.3 interface gets the Compat\V0_3 transports, as in Python. |
card_resolver.py A2ACardResolver, parse_agent_card() |
A2ACardResolver, A2ACardResolver::parseAgentCard() |
Includes the pre-1.0 card field mapping. http_kwargs → ['headers' => [...], 'timeout' => ...]. |
interceptors.py ClientCallInterceptor, BeforeArgs, AfterArgs |
same names | Interface instead of ABC; methods are synchronous. |
auth/credentials.py, auth/interceptor.py |
Auth\CredentialService (interface), Auth\InMemoryContextCredentialStore, Auth\AuthInterceptor |
|
errors.py |
Errors\A2AClientError, Errors\A2AClientTimeoutError, Errors\AgentCardResolutionError |
All extend A2A\Utils\Errors\A2AError, as in Python. |
service_parameters.py ServiceParametersFactory, with_a2a_extensions() |
ServiceParametersFactory, ServiceParameters::withA2aExtensions() |
Updates return the new array (PHP arrays are values) instead of mutating a dict. |
transports/base.py ClientTransport |
Transports\ClientTransport (interface) |
|
transports/jsonrpc.py, transports/rest.py |
Transports\JsonRpcTransport, Transports\RestTransport |
Take an HttpSender where Python takes an httpx.AsyncClient. Every request carries A2A-Version: 1.0 (Python sets it on the factory's shared client). |
transports/tenant_decorator.py TenantTransportDecorator |
Transports\TenantTransportDecorator |
|
transports/http_helpers.py |
Transports\HttpHelpers (internal) + Sse\EventStreamParser |
The SSE parser is incremental (bytes arrive in arbitrary chunks); same rules as Python's parse_sse_stream(). |
transports/grpc.py |
— | After 1.0. |
| (httpx) | Http\HttpSender + Psr18HttpSender, GuzzleHttpSender, SymfonyHttpSender, HttpSenderFactory |
PHP-only: PSR-18 can't stream, so sending goes through this small interface. Guzzle and Symfony stream live; other PSR-18 clients get the buffered body. Guzzle stream requests go out as HTTP/1.0 (PHP's http:// wrapper holds chunked HTTP/1.1 bodies back until they end). |
Compat, tooling, tests¶
| Python | PHP | Notes |
|---|---|---|
compat/v0_3/conversions.py |
Compat\V0_3\Conversions |
Function for function (to_core_x → toCoreX). v0.3 objects are the decoded JSON-RPC JSON (\stdClass) instead of pydantic models; request converters take the JSON-RPC params. |
compat/v0_3/types.py |
— | No model classes: the v0.3 JSON-RPC shape stays decoded JSON. |
compat/v0_3/proto_utils.py ToProto, FromProto |
Compat\V0_3\ToProto, FromProto |
v0.3 proto ↔ v0.3 JSON, for the REST binding. Keeps the status timestamp (Python drops it). |
compat/v0_3/a2a_v0_3_pb2.py |
Compat\V0_3\Types\* (generated) |
From the same v0.3 proto commit Python pins, vendored as proto/v0_3/a2a_v0_3.proto. |
compat/v0_3/versions.py, extension_headers.py, context_builders.py |
Versions, ExtensionHeaders, V03ServerCallContextBuilder |
|
compat/v0_3/request_handler.py RequestHandler03 |
RequestHandler03 |
|
compat/v0_3/jsonrpc_adapter.py JSONRPC03Adapter |
JsonRpc03Adapter |
Plugged into JsonRpcDispatcher by enableV03Compat. A2A errors keep their code (Python: -32603). |
compat/v0_3/rest_adapter.py, rest_handler.py REST03Adapter, REST03Handler |
Rest03Adapter, Rest03Handler |
Plugged into RestDispatcher by enableV03Compat. Also serves DELETE on a push config; the card comes back camelCase. |
compat/v0_3/jsonrpc_transport.py, rest_transport.py CompatJsonRpcTransport, CompatRestTransport |
same names | Always state blocking. REST list/delete push configs use the v0.3 proto routes (Python: NotImplementedError). |
compat/v0_3/grpc_* |
— | No gRPC. |
request_handlers/response_helpers.py agent_card_to_dict() |
Server\RequestHandlers\ResponseHelpers::agentCardToDict() |
Merges the v0.3 card fields when the card offers a v0.3 interface. |
a2a_db_cli.py, migrations/ (alembic) |
bin/a2a-db (schema SQL) + Laravel migrations in the bridge |
|
samples/hello_world_agent.py, cli.py |
examples/hello-world/server.php, examples/cli.php |
Line-for-line port. It is the first thing a Python user looks for. |
tck/sut_agent.py |
tck/sut-agent.php |
The system-under-test agent the A2A TCK runs against in CI. |
itk/ |
itk/ |
Cross-SDK tests: Python client ↔ PHP server and PHP client ↔ Python server. |
tests/ (pytest, 118 files) |
tests/ (PHPUnit), same folder layout |
Port test by test. The Python tests are our spec for behaviour the written spec leaves open. |
Python features that don't carry over directly¶
| Python | Why it's different in PHP | PHP answer |
|---|---|---|
asyncio tasks, async for |
PHP-FPM handles one request at a time; there is no event loop | Generators for streams; a TaskRunner abstraction for background execution (see architecture.md) |
asyncio.CancelledError on cancel |
A running PHP call can't be interrupted from outside | Cooperative: a CancellationToken in RequestContext that executors check ($context->isCancelled()), and the store's cancel flag across processes |
Empty Struct on the wire |
The pure-PHP protobuf runtime writes an empty Struct (e.g. metadata) as [], not {} |
Known runtime quirk. Leave metadata unset rather than empty |
| Pydantic models | — | readonly classes for SDK-side config objects; protobuf classes for wire types |
httpx |
PSR-18 can only return complete responses, so it can't stream SSE | Client\Http\HttpSender: Guzzle and Symfony HttpClient stream live; any other PSR-18 client works with buffered streams |