Skip to content

Laravel: install and first agent

The Laravel bridge (praveendias1180/a2a-laravel) turns the SDK into a few lines of Laravel: a route macro, an executor class resolved from the container, and config for queues and storage. All protocol behaviour stays in the core SDK, so a Laravel agent passes the same A2A TCK as a plain-PHP one.

Laravel 12 and 13 on PHP 8.2+ (tested in CI). Laravel 11 is allowed by the constraints but untested: Composer blocks every Laravel 11 release for open security advisories.

Install

composer require praveendias1180/a2a-laravel
php artisan vendor:publish --tag=a2a-migrations
php artisan migrate
php artisan vendor:publish --tag=a2a-config   # optional: config/a2a.php

The migration creates the task table, the event log and the push-config table on your default connection (change it with a2a.storage.connection).

An agent in three files

1. The executor. php artisan a2a:make-executor Hello writes app/A2A/HelloExecutor.php:

// What `php artisan a2a:make-executor Hello` generates, with a reply filled in.
final class HelloExecutor implements AgentExecutor
{
    public function execute(RequestContext $context, EventQueue $eventQueue): void
    {
        $message = $context->message();
        if ($context->currentTask() === null && $message !== null) {
            $eventQueue->enqueueEvent(ProtoHelpers::newTaskFromUserMessage($message));
        }

        $updater = new TaskUpdater($eventQueue, (string) $context->taskId(), (string) $context->contextId());
        $updater->startWork();
        $updater->addArtifact([new Part(['text' => 'Hello, ' . $context->getUserInput()])], name: 'response', lastChunk: true);
        $updater->complete();
    }

    public function cancel(RequestContext $context, EventQueue $eventQueue): void
    {
        (new TaskUpdater($eventQueue, (string) $context->taskId(), (string) $context->contextId()))->cancel();
    }
}

2. The card. A class implementing AgentCardProvider (or an array in config/a2a.php):

final class HelloAgentCard implements AgentCardProvider
{
    public function agentCard(): AgentCard
    {
        // No supported_interfaces: Route::a2a() fills them in from your routes.
        return new AgentCard([
            'name' => 'Hello Agent',
            'description' => 'Says hello.',
            'version' => '1.0.0',
            'capabilities' => new AgentCapabilities(['streaming' => true]),
            'default_input_modes' => ['text/plain'],
            'default_output_modes' => ['text/plain'],
            'skills' => [new AgentSkill([
                'id' => 'hello',
                'name' => 'Hello',
                'description' => 'Say hi.',
                'tags' => ['demo'],
            ])],
        ]);
    }
}

3. The route. In routes/api.php or any routes file:

Route::a2a('/a2a', agentCard: HelloAgentCard::class, executor: HelloExecutor::class);

That registers:

Route Name What
GET /.well-known/agent-card.json a2a.default.well-known the card, for discovery (pass wellKnown: false to skip)
GET /a2a/.well-known/agent-card.json a2a.default.card the same card under the prefix
POST /a2a/jsonrpc a2a.default.jsonrpc the JSON-RPC binding
GET/POST/DELETE /a2a/rest/... a2a.default.rest the HTTP+JSON binding

Leave supported_interfaces out of the card and the bridge fills them in from these routes. CSRF protection is left off the protocol routes: A2A clients are programs, not browsers.

Check it:

php artisan a2a:card        # prints the card as served and validates it

Authentication and task owners

Add middleware to the protocol routes. The card routes stay public, as discovery requires:

Route::a2a('/a2a', agentCard: HelloAgentCard::class, executor: HelloExecutor::class)
    ->middleware('auth:sanctum');

Or let the card drive it. Map each security scheme your card declares in securityRequirements to middleware:

// config/a2a.php
'security_schemes' => [
    'bearer' => 'auth:sanctum',
],

Tasks belong to the authenticated user (the auth identifier of a2a.guard, the default guard if null). Another user asking for your task gets "task not found", exactly as if it did not exist. Unauthenticated callers share one scope.

Several agents

Name them, and give each its own prefix:

Route::a2a('/support', agentCard: SupportCard::class, executor: SupportExecutor::class, agent: 'support');
Route::a2a('/billing', agentCard: BillingCard::class, executor: BillingExecutor::class, agent: 'billing', wellKnown: false);

Only one agent can own /.well-known/agent-card.json; the others keep their card under their prefix. Agents can also live in config/a2a.php under agents and be mounted with Route::a2a('/support', agent: 'support'). Everything the routes need is stored as strings and arrays, so php artisan route:cache works.

Calling other agents

    public function ask(string $url, string $text): string
    {
        $client = A2A::client($url); // fetches $url/.well-known/agent-card.json

        $request = new SendMessageRequest(['message' => new Message([
            'message_id' => (string) \Illuminate\Support\Str::uuid(),
            'role' => Role::ROLE_USER,
            'parts' => [new Part(['text' => $text])],
        ])]);

        $answer = '';
        foreach ($client->sendMessage($request) as $event) {
            if ($event->hasArtifactUpdate()) {
                foreach ($event->getArtifactUpdate()->getArtifact()?->getParts() ?? [] as $part) {
                    $answer .= $part->getText();
                }
            } elseif ($event->hasTask()) {
                foreach ($event->getTask()->getArtifacts() as $artifact) {
                    foreach ($artifact->getParts() as $part) {
                        $answer .= $part->getText();
                    }
                }
            }
        }

        return $answer;
    }

Artisan commands

Command What
a2a:make-executor Name writes app/A2A/NameExecutor.php
a2a:card [agent] prints an agent's card as served and checks it (exit 1 when invalid)
a2a:prune [--days=7] deletes finished tasks, their push configs and old database events (see storage)
a2a:tck --tck-dir=... runs the official A2A TCK against your app

Next