Skip to content

phpClaw for Symfony

The Symfony adapter is a bundle, PhpClaw\Symfony\PhpClawBundle. It adds console commands, two REST routes (three more for durable runs), Doctrine memory, Messenger jobs, a Web Profiler panel, and three 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, PHPCLAW_CLOUD_SIGNING_SECRET, PHPCLAW_FALLBACK_PROVIDER, PHPCLAW_FALLBACK_MODEL, PHPCLAW_FALLBACK_API_KEY, PHPCLAW_RATE_LIMIT_RPM, PHPCLAW_RESPONSE_CACHE, PHPCLAW_RESPONSE_CACHE_TTL, PHPCLAW_MAX_TOKEN_BUDGET, PHPCLAW_MAX_TOOLS_PER_TURN, PHPCLAW_DURABLE_RUNS, PHPCLAW_DURABLE_STEP_BUDGET and PHPCLAW_DURABLE_DEADLINE_SECONDS 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: ls, pwd, df, wc, date, uptime, hostname, whoami
tools[]
tool_deny[]
max_tools_per_turn0, the automatic per-turn limit; -1 sends every tool; below -1 is rejected
always_include_tools[]; offered on every turn, outside the limit
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[]
fallback_provider'', off
fallback_model'', the fallback provider’s default
fallback_api_key''
rate_limit_rpm0, off; held between 0 and 600
response_cachefalse
response_cache_ttl3600, held between 60 and 86400
max_token_budget0, off
durable_runsfalse
durable_step_budget0, no step limit
durable_deadline_secondsnull, half of max_execution_time

The seven options from fallback_provider to max_token_budget switch on the building blocks. The three durable_* options are covered in Pause for approval and finish later. A fallback_provider that is custom, or that does not share the main provider’s tool format, is skipped. The rate limit and the response cache use the cache.app pool; without that service both stay off.

tool_deny, always_include_tools 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 files in storage/phpclaw/memory under the process’s working directory, which is public/ for web requests. Do not use it for web trafficalways
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, a name other than doctrine or doctrine_conversation stops the boot instead, as described below.

With the REST API enabled, memory_driver must be doctrine or doctrine_conversation, the drivers that enforce conversation ownership, and doctrine/dbal must be installed so that they register. Any other driver name 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 two migrations in PhpClaw\Symfony\Migrations but does not register the path. Version20240101000001 creates the tables; Version20240101000002 adds the user_id owner column to a conversations table created without it, and changes nothing otherwise. Add the path 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 three default tools: code_search, file_read and project_info. code_search and file_read work inside workspace_root. See Tools.

shell_exec, http_request, file_write and file_edit are opt-in. List their classes in tools to register them:

phpclaw:
tools:
- PhpClaw\Tools\ShellTool
- PhpClaw\Tools\HttpTool
- PhpClaw\Tools\FileWriteTool
- PhpClaw\Tools\FileEditTool

ShellTool is built with shell_allowlist, and FileWriteTool and FileEditTool with workspace_root. HttpTool is built with no arguments. The shell allowlist matches the first word of the command exactly, case included.

The three 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
test_runPhpClaw\Symfony\Tools\TestRunToolany logged-in user, or ROLE_PHPCLAW_CHAT with require_chat_role: true
phpclaw:
tools:
- PhpClaw\Symfony\Tools\LogTool
- PhpClaw\Symfony\Tools\DatabaseTool
- PhpClaw\Symfony\Tools\TestRunTool

test_run runs one fixed command, vendor/bin/phpunit, with an optional --filter that may hold only letters, digits, _, \ and :. It runs only from a console command in a terminal, is refused when the kernel environment is prod, returns NOT_INSTALLED when vendor/bin/phpunit is missing, and you approve every run. It has no timeout.

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 and project_info.

  • 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 ...",
"acting_user": "...",
"remedy": "Run this request synchronously rather than through the queue."
},
"data": null,
"meta": { "mode": "error" },
"warnings": []
}

With durable_runs off, the default, 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.

Outside a terminal that refusal covers every file_write and file_edit call, and any shell_exec command outside core’s read-only command list, so those complete only from a console command run in a terminal. With durable_runs on, the run pauses instead, and someone decides later: see Pause for approval and finish later.

A file_write listed in tools may create .php files in bin/console phpclaw and bin/console phpclaw:chat. Web requests, Messenger workers, the other console commands and the MCP server’s tools never may.


Nine commands ship:

CommandWhat it does
bin/console phpclaw "message"Send a message. --stream prints tokens as they arrive; --conv-id=, --continue and --trace as below
bin/console phpclaw:chatOpen an interactive session in one conversation. Options: --conv-id=, --continue
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:runs <action>Durable runs: list, approve <run> <call>, deny <run> <call> --reason=, resume <run>, resume-due --limit=
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.

After each reply bin/console phpclaw prints the provider, model, tokens, the tools that ran and the conversation id. --conv-id=<id> continues that stored conversation; an id with no stored conversation prints a warning and starts a new one. --continue picks the last conversation, whose id is kept in phpclaw/.last-conversation under the kernel cache folder, so cache:clear forgets it. Both need a stored memory driver: with array the command warns and starts a new conversation. --trace prints one line per tool call, the tool name plus a short path or command, never the result.

bin/console phpclaw:chat keeps one conversation open, streams each reply and prints the tool calls as they run. It needs a terminal: with piped input it refuses and exits 1. A line starting with / is a command and is never sent to the agent:

CommandWhat it does
/helpList these commands
/toolsList the active tools
/model <name>Switch the model for this session only
/provider <slug>Switch the provider for this session only
/newStart a new conversation
/convPrint the conversation id
/quietTurn the tool trace off or on
/clearClear the screen
/exitLeave; Ctrl+D also leaves

While the agent works, a Thinking... spinner shows until the first word of the reply arrives. It hides before each tool call is traced or approved and shows again while the model works after it. It is not shown when the output is not a terminal, and without the pcntl extension a still Thinking... line is printed instead of the animation.

On leaving it prints bin/console phpclaw:chat --conv-id=<id> to resume. /model and /provider never write config or .env.

When the kernel environment is prod and workspace_root is outside var/phpclaw, both phpclaw:chat and --trace refuse to start.


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
GET/phpclaw/runsruns waiting for the caller’s decision
POST/phpclaw/runs/{runId}/approveapprove, then the finished run
POST/phpclaw/runs/{runId}/denydeny, then the finished run

The three runs routes are always registered and answer 404 while durable_runs is off.

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, for all five routes, 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
202with durable_runs on, the run paused for approval (status awaiting_approval) or spent its budget (suspended); the body is the run described below
400message is empty
403the conversation belongs to another user
422a guard blocked the message, or the token budget was reached: {"error": "Token budget reached for this run."}
429the rate_limit_rpm wait ran out: {"error": "Rate limit reached, try again shortly."}. The API’s own per-client limit also answers 429, with {"error": "Too many requests."}
500anything else failed; no detail is returned

chat/stream sends the events tool_before, tool_after, chunk (with text), done and error. A spent budget or the rate limit arrives as an error event carrying the same message. With durable_runs on, a paused run ends the stream with approval_required, and a run that spent its budget with run_suspended, each carrying the run below.


Set durable_runs to true. It needs store_messages; without it the setting is ignored and a warning is logged. Then:

  • A tool that changes data pauses the run instead of being refused, in a web request, a Messenger worker and any console command except bin/console phpclaw and bin/console phpclaw:chat, which keep the Y/n prompt. Without a terminal that prompt still refuses the change.
  • Every run is saved after each step. A run that uses up durable_step_budget steps or durable_deadline_seconds seconds stops and is finished later. With durable_deadline_seconds unset, the console has no deadline, because max_execution_time is 0 there.
  • Nothing finishes stopped runs by itself. Add bin/console phpclaw:runs resume-due to cron, every minute. It also restarts a run left running by a process that died, once its last save is 900 seconds old.
  • When a run started by bin/console phpclaw spends its budget, the command prints the run id and bin/console phpclaw:runs resume <run>, and exits 0.

A stopped run is described as:

{
"run_id": "01M48V49SA27PRBD0S3JB5883F",
"status": "awaiting_approval",
"conversation_id": "01M48V49RS7CSM85D772TAR24N",
"pending": { "call_id": "call_7xkd7ddj", "tool_name": "file_write", "tool_input": { "path": "notes.txt", "content": "hello" } }
}

pending is null for a suspended run. Approve or deny with the paused call’s id; the run then finishes in the same request:

Terminal window
curl -X POST "https://your-app.example/phpclaw/runs/<run_id>/approve" \
-H "Content-Type: application/json" \
-d '{"call_id":"<call_id>"}'

deny takes call_id and an optional reason. A call_id that is missing or longer than 200 characters, or a reason longer than 500, returns 422. A run belongs to the user who started its conversation: GET /phpclaw/runs lists only the caller’s runs, all of them for a ROLE_PHPCLAW_MANAGE_ALL holder, and deciding anyone else’s run returns 403. An unknown run returns 404, and a call id that is not waiting, or a run another request changed first, 409. A finished run answers 200 with status completed, run_id, conversation_id, text, provider, model, iterations and tokens; a run that stops again answers 202 with the run. The finished answer is added to the run’s conversation.

From a terminal, phpclaw:runs decides and resumes any run, acting as the run’s owner, so the answer lands in that user’s conversation.


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 callers with no user, such as console commands, and 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, or, with durable_runs on, suspended, with run_id and run_status: the run paused or spent its budget, the job is not retried, and the run finishes through phpclaw:runs. The job’s stored result stays suspended after its run finishes. phpclaw:jobs:status exits with 5 for a suspended job. 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, 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.