Skip to content

phpClaw for Symfony

The Symfony adapter is a bundle, PhpClaw\Symfony\PhpClawBundle. It adds console commands, two REST routes, Doctrine memory, Messenger jobs, a Web Profiler panel, and two Symfony tools you can switch on.


RequirementVersion
PHP8.1 or later
Symfony6.4, 7 or 8
Terminal window
composer require phpclaw/phpclaw-symfony

If Symfony Flex has not added the bundle, register it in config/bundles.php:

return [
// ...
PhpClaw\Symfony\PhpClawBundle::class => ['all' => true],
];

Some features need packages the adapter only suggests:

PackageNeeded for
doctrine/dbalthe default doctrine memory driver, db_query and phpclaw:stats
doctrine/migrationscreating the three phpClaw tables
symfony/security-bundlethe REST API, which needs a logged-in user
symfony/messengerqueued jobs

Set one provider key in .env and send a message:

ANTHROPIC_API_KEY=sk-ant-...
Terminal window
bin/console phpclaw "check database connections"

phpClaw in a Symfony app


The bundle has a default for every option, but reads no PHPCLAW_* variable by itself. Copy the shipped file to connect the variables:

Terminal window
cp vendor/phpclaw/phpclaw-symfony/config/phpclaw.yaml config/packages/phpclaw.yaml

That file maps PHPCLAW_PROVIDER, PHPCLAW_MODEL, PHPCLAW_BASE_URL, PHPCLAW_API_ENABLED, PHPCLAW_STORE_MESSAGES, PHPCLAW_MAX_ITERATIONS, PHPCLAW_MAX_TOKENS, PHPCLAW_MEMORY_DRIVER, PHPCLAW_SYSTEM_PROMPT, PHPCLAW_REMOTE_SKILL_URLS, PHPCLAW_CLOUD_KEY and PHPCLAW_CLOUD_SIGNING_SECRET onto the options below.

OptionBundle default
api_key'', read from the provider’s own variable
provider'', detected from your keys
model'', the provider’s default
base_url''
store_messagestrue
max_iterations20
max_tokens0, the provider’s default
prompt_cachefalse
thinking_budget0, off
system_prompt'', cut to 8000 characters
memory_driverdoctrine
workspace_root%kernel.project_dir%/var/phpclaw
shell_allowlistcore’s default allowlist
tools[]
tool_deny[]
require_chat_rolefalse
worker_commands[]
guards[]
hooks[]
skills[]
remote_skill_urls'', comma separated, at most 50 are loaded
event_bridgetrue
api.enabledtrue
cloud_key''
cloud_signing_secret''
cloud_disable[]

tool_deny and cloud_disable are YAML lists and take no environment variable.

api accepts only enabled. There is no shared API token, and a token key under phpclaw.api fails configuration loading with Symfony’s unrecognised-option error.

With api_key empty, core reads the key for the provider you choose. With both ANTHROPIC_API_KEY and OPENAI_API_KEY set and provider: openai, the OpenAI key is used. See Providers.

Set provider: custom and base_url to an OpenAI-compatible endpoint. The URL must use https://, or http:// on localhost, 127.0.0.1 or ::1. Any other URL is ignored and the provider is detected from your keys instead.

The three cloud_* options configure phpClaw Cloud. Cloud boots only while store_messages is on.


DriverStoresRegistered when
doctrine (default)conversations in phpclaw_conversations and phpclaw_messages, everything else in phpclaw_memorythe container has a Doctrine DBAL Connection service
doctrine_conversationconversation tables onlythe same
doctrine_kvphpclaw_memory onlythe same
fileJSON filesalways
arraythe current process onlyalways
redisRedis, through core’s RedisMemoryalways

If memory_driver names a driver that is not registered, the agent uses file memory in workspace_root, with no error.

With the REST API enabled, memory_driver must be doctrine or doctrine_conversation, the drivers that enforce conversation ownership. Any other driver stops the kernel from booting, with a message naming the setting to change. Set api.enabled: false to use another driver.

With store_messages off, the agent stores no message content, whichever driver you use. See Memory.

The bundle ships its migration as PhpClaw\Symfony\Migrations\Version20240101000001 but does not register the path. Add it to your Doctrine Migrations config, then migrate:

config/packages/doctrine_migrations.yaml
doctrine_migrations:
migrations_paths:
'PhpClaw\Symfony\Migrations': '%kernel.project_dir%/vendor/phpclaw/phpclaw-symfony/src/Migrations'
Terminal window
bin/console doctrine:migrations:migrate

Without any configuration the agent gets core’s seven default tools: shell_exec, http_request, file_read, file_write, file_edit, code_search and project_info. See Tools.

The two Symfony tools are not registered until you list them in tools:

Tool nameClassWho may call it
read_logPhpClaw\Symfony\Tools\LogToolany logged-in user, or ROLE_PHPCLAW_CHAT with require_chat_role: true
db_queryPhpClaw\Symfony\Tools\DatabaseToolROLE_PHPCLAW_MANAGE_ALL
phpclaw:
tools:
- PhpClaw\Symfony\Tools\LogTool
- PhpClaw\Symfony\Tools\DatabaseTool

db_query is skipped when there is no Doctrine DBAL connection. Any other class in tools must be constructible without arguments, or it is skipped. To add a tool that needs services, use phpclaw.booting.

tool_deny removes tools by name or by group:system, which holds db_query, read_log, http_request, file_read, file_write and shell_exec. There are no other groups.

phpclaw:
tool_deny: ['group:system']

With the default tools, that leaves code_search, project_info and file_edit.

  • Roles are checked through Symfony’s authorization checker. The user id is getUserIdentifier().
  • Console commands skip the check. While a console command runs, the tools run without it. messenger:consume, messenger:failed:retry, messenger:stats and any command in worker_commands are not treated as console commands.
  • Queued runs carry the user, not the user’s roles. In a Messenger job, a tool that needs a role (db_query, or read_log with require_chat_role: true) is refused with the error code FORBIDDEN_IN_QUEUED_RUN, even if the user holds the role. Run that request synchronously. Roles are not copied onto the message on purpose: a job consumed after a role was revoked would otherwise still run with it.
{
"success": false,
"error": {
"code": "FORBIDDEN_IN_QUEUED_RUN",
"message": "A queued run carries the dispatching user's identity but not their roles, so the \"ROLE_PHPCLAW_MANAGE_ALL\" role cannot be evaluated and this tool is unavailable asynchronously. Run the same request synchronously to ...",
"remedy": "Run this request synchronously rather than through the queue."
}
}

Every agent is built with CliApprovalGate. In a terminal you are asked before a tool changes something; elsewhere there is nobody to ask, so the change is refused. See Security: approval.

file_write may create .php files only when the agent is built during a console command. The MCP server’s tools never may.


Seven commands ship:

CommandWhat it does
bin/console phpclaw "message"Send a message. --stream prints tokens as they arrive
bin/console phpclaw:aboutShow the adapter’s state. --test also sends a test prompt
bin/console phpclaw:guidePrint the adapter guide. --section= prints one of quickstart, tools, providers, memory, guards, hooks, skills, rest, cli, privacy, config; --live fetches the remote skill URLs
bin/console phpclaw:statsCount conversations, messages, and conversations active in the last 24 hours. Works with the doctrine and doctrine_conversation drivers only
bin/console phpclaw:jobs:listList stored results of queued jobs
bin/console phpclaw:jobs:status <jobId>Show one queued job’s status and result
bin/console phpclaw:mcp-serverStart the MCP server. --transport=stdio (default) or --transport=http

phpclaw:stats counts the whole installation, and the two jobs commands show every job. The MCP server is covered on the MCP page.


Symfony does not load bundle routes by itself. Import them:

config/routes/phpclaw.yaml
phpclaw:
resource: '@PhpClawBundle/config/routes.yaml'
MethodRouteReturns
POST/phpclaw/sendthe reply as JSON
POST/phpclaw/chat/streamServer-Sent Events

The body is JSON with message and an optional conversation_id:

Terminal window
curl -X POST "https://your-app.example/phpclaw/send" \
-H "Content-Type: application/json" \
-d '{"message":"how many users signed up this week?"}'

A request listener checks, in order:

StatusWhen
403api.enabled is false
429the client IP has made 60 requests in the current 60-second window
401no user is logged in
403conversation_id belongs to another user

These checks run in PhpClaw\Symfony\Http\TokenAuthListener on the request event, so a request naming another user’s conversation gets 403 before the controller runs, and on chat/stream before any event is sent.

The rate limit is kept in the cache.app pool. There is no API token; put the routes behind a firewall so requests resolve to a user. Without symfony/security-bundle, no request resolves to a user, so every call answers 401; the security services are injected as optional (@?security.token_storage), so the bundle still loads without it.

send returns:

{
"text": "...",
"tool_calls": [],
"provider": "anthropic",
"model": "...",
"iterations": 1,
"tokens": 150,
"conversation_id": "..."
}
StatusWhen
400message is empty
403the conversation belongs to another user
422a guard blocked the message
500anything else failed; no detail is returned

chat/stream sends the events tool_before, tool_after, chunk (with text), done and error.


Every conversation stores its owner in phpclaw_conversations.user_id, a string of up to 180 characters holding the user’s getUserIdentifier(), such as an email address. Conversations created from the console have no user and store an empty string, and only ROLE_PHPCLAW_MANAGE_ALL holders see them. The column is a string because Symfony guarantees only getUserIdentifier(), not a numeric id.

SymfonyIdentityResolver reads the identifier from the token storage. DoctrineConversationMemory enforces ownership and throws ConversationAccessDeniedException, which AbstractDoctrineMemory::guard() rethrows rather than turning into a generic memory error. The migration indexes (user_id, namespace, updated_at) as idx_phpclaw_conversations_user_ns_updated.

A user lists and opens only their own conversations. Opening another user’s conversation is refused, not returned empty. ROLE_PHPCLAW_MANAGE_ALL lets a user see every conversation and every queued job. Grant it like any other role:

config/packages/security.yaml
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_PHPCLAW_MANAGE_ALL]

The agent is a shared service. Autowire PhpClaw\Contracts\ClawInterface or PhpClaw\Claw:

use PhpClaw\Contracts\ClawInterface;
final class HealthService
{
public function __construct(private readonly ClawInterface $agent) {}
public function check(string $question): string
{
return $this->agent->send($question)->text;
}
}

stream(), conversation() and sendInConversation() work as described in Getting Started.


Install Messenger and route the message to a transport:

config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'PhpClaw\Symfony\Queue\RunAgentMessage': async
use PhpClaw\Symfony\Queue\QueueManager;
$jobId = $queue->dispatchSend('Generate the monthly report.');
$result = $queue->pollResult($jobId);
Terminal window
bin/console messenger:consume async

dispatchSend() throws when Messenger is not installed. Its second argument is how long the result is kept, in seconds, by default 3600. The job runs as the user who dispatched it, without that user’s roles (see above).

pollResult() returns null while the job is pending, after the result expires, and when the job belongs to another user. Otherwise it returns an array whose status is done, with text, provider, model, tokens, iterations, duration_ms, at and run_id, or failed, with error. The result, including the reply text, is stored even when store_messages is off.


With event_bridge on, every phpClaw lifecycle event is also dispatched on Symfony’s event dispatcher as phpclaw. followed by the event name. The event object is PhpClaw\Symfony\Events\PhpClawEvent, with name and context:

use PhpClaw\Symfony\Events\PhpClawEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: 'phpclaw.agent.after')]
final class AgentLogger
{
public function __invoke(PhpClawEvent $event): void
{
// $event->name, $event->context
}
}

The event names are listed on the Hooks page.

With event_bridge: false, phpClaw’s own hooks still fire; only the Symfony dispatch stops.

While it boots, the bundle dispatches phpclaw.booting with a PhpClaw\Symfony\Extension\PhpClawExtensions object. Add to its tools, guards, hooks, skills, memory and providers arrays from a listener:

use PhpClaw\Symfony\Extension\PhpClawExtensions;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: 'phpclaw.booting')]
final class RegisterPhpClawTools
{
public function __construct(private readonly MyTool $tool) {}
public function __invoke(PhpClawExtensions $extensions): void
{
$extensions->tools[] = $this->tool;
}
}
phpclaw:
guards:
- { class: App\Guards\ProfanityGuard, priority: 5 }
hooks:
- { event: agent.before, handler: ['App\Listeners\AuditLog', 'handle'], priority: 10 }

priority defaults to 10. handler must be callable as written, such as a static method. A guard class is taken from the container when it is a service, and created without arguments otherwise. See Guards and Hooks.


When the Web Profiler is enabled, a phpClaw panel shows, for each request:

  • each agent run: provider, model, iterations, duration, input, output and total tokens, tools called
  • each tool call: tool name, iteration, and the tool input, with sk- keys, bearer tokens, and name=value or name: value pairs whose name contains key, token, secret, password or passwd, replaced by [redacted]. Only text inside a string is scanned: tool input normally arrives as an array, and a value stored under an array key such as api_key is shown as is
  • each guard block: the reason

The panel reads the bridged events, so turning event_bridge off empties it.