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.
Install
Section titled “Install”| Requirement | Version |
|---|---|
| PHP | 8.1 or later |
| Symfony | 6.4, 7 or 8 |
composer require phpclaw/phpclaw-symfonyIf 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:
| Package | Needed for |
|---|---|
doctrine/dbal | the default doctrine memory driver, db_query and phpclaw:stats |
doctrine/migrations | creating the three phpClaw tables |
symfony/security-bundle | the REST API, which needs a logged-in user |
symfony/messenger | queued jobs |
Set one provider key in .env and send a message:
ANTHROPIC_API_KEY=sk-ant-...bin/console phpclaw "check database connections"
Configuration
Section titled “Configuration”The bundle has a default for every option, but reads no PHPCLAW_* variable by itself. Copy the
shipped file to connect the variables:
cp vendor/phpclaw/phpclaw-symfony/config/phpclaw.yaml config/packages/phpclaw.yamlThat 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.
| Option | Bundle default |
|---|---|
api_key | '', read from the provider’s own variable |
provider | '', detected from your keys |
model | '', the provider’s default |
base_url | '' |
store_messages | true |
max_iterations | 20 |
max_tokens | 0, the provider’s default |
prompt_cache | false |
thinking_budget | 0, off |
system_prompt | '', cut to 8000 characters |
memory_driver | doctrine |
workspace_root | %kernel.project_dir%/var/phpclaw |
shell_allowlist | core’s default allowlist: ls, pwd, df, wc, date, uptime, hostname, whoami |
tools | [] |
tool_deny | [] |
max_tools_per_turn | 0, 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_role | false |
worker_commands | [] |
guards | [] |
hooks | [] |
skills | [] |
remote_skill_urls | '', comma separated, at most 50 are loaded |
event_bridge | true |
api.enabled | true |
cloud_key | '' |
cloud_signing_secret | '' |
cloud_disable | [] |
fallback_provider | '', off |
fallback_model | '', the fallback provider’s default |
fallback_api_key | '' |
rate_limit_rpm | 0, off; held between 0 and 600 |
response_cache | false |
response_cache_ttl | 3600, held between 60 and 86400 |
max_token_budget | 0, off |
durable_runs | false |
durable_step_budget | 0, no step limit |
durable_deadline_seconds | null, 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.
The API key
Section titled “The API key”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.
A custom endpoint
Section titled “A custom endpoint”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.
Memory
Section titled “Memory”| Driver | Stores | Registered when |
|---|---|---|
doctrine (default) | conversations in phpclaw_conversations and phpclaw_messages, everything else in phpclaw_memory | the container has a Doctrine DBAL Connection service |
doctrine_conversation | conversation tables only | the same |
doctrine_kv | phpclaw_memory only | the same |
file | JSON files in storage/phpclaw/memory under the process’s working directory, which is public/ for web requests. Do not use it for web traffic | always |
array | the current process only | always |
redis | Redis, through core’s RedisMemory | always |
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.
Creating the tables
Section titled “Creating the tables”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:
doctrine_migrations: migrations_paths: 'PhpClaw\Symfony\Migrations': '%kernel.project_dir%/vendor/phpclaw/phpclaw-symfony/src/Migrations'bin/console doctrine:migrations:migrateWhat is registered
Section titled “What is registered”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\FileEditToolShellTool 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 name | Class | Who may call it |
|---|---|---|
read_log | PhpClaw\Symfony\Tools\LogTool | any logged-in user, or ROLE_PHPCLAW_CHAT with require_chat_role: true |
db_query | PhpClaw\Symfony\Tools\DatabaseTool | ROLE_PHPCLAW_MANAGE_ALL |
test_run | PhpClaw\Symfony\Tools\TestRunTool | any 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\TestRunTooltest_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.
Denying tools
Section titled “Denying tools”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.
Who may call a Symfony tool
Section titled “Who may call a Symfony tool”- 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:statsand any command inworker_commandsare 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, orread_logwithrequire_chat_role: true) is refused with the error codeFORBIDDEN_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": []}Approval and PHP files
Section titled “Approval and PHP files”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.
Console commands
Section titled “Console commands”Nine commands ship:
| Command | What 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:chat | Open an interactive session in one conversation. Options: --conv-id=, --continue |
bin/console phpclaw:about | Show the adapter’s state. --test also sends a test prompt |
bin/console phpclaw:guide | Print 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:stats | Count conversations, messages, and conversations active in the last 24 hours. Works with the doctrine and doctrine_conversation drivers only |
bin/console phpclaw:jobs:list | List 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-server | Start 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.
One-shot options and the chat session
Section titled “One-shot options and the chat session”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:
| Command | What it does |
|---|---|
/help | List these commands |
/tools | List the active tools |
/model <name> | Switch the model for this session only |
/provider <slug> | Switch the provider for this session only |
/new | Start a new conversation |
/conv | Print the conversation id |
/quiet | Turn the tool trace off or on |
/clear | Clear the screen |
/exit | Leave; 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.
REST API
Section titled “REST API”Symfony does not load bundle routes by itself. Import them:
phpclaw: resource: '@PhpClawBundle/config/routes.yaml'| Method | Route | Returns |
|---|---|---|
| POST | /phpclaw/send | the reply as JSON |
| POST | /phpclaw/chat/stream | Server-Sent Events |
| GET | /phpclaw/runs | runs waiting for the caller’s decision |
| POST | /phpclaw/runs/{runId}/approve | approve, then the finished run |
| POST | /phpclaw/runs/{runId}/deny | deny, 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:
curl -X POST "https://your-app.example/phpclaw/send" \ -H "Content-Type: application/json" \ -d '{"message":"how many users signed up this week?"}'Before the controller runs
Section titled “Before the controller runs”A request listener checks, in order:
| Status | When |
|---|---|
| 403 | api.enabled is false |
| 429 | the client IP has made 60 requests in the current 60-second window |
| 401 | no user is logged in |
| 403 | conversation_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.
Responses
Section titled “Responses”send returns:
{ "text": "...", "tool_calls": [], "provider": "anthropic", "model": "...", "iterations": 1, "tokens": 150, "conversation_id": "..."}| Status | When |
|---|---|
| 202 | with durable_runs on, the run paused for approval (status awaiting_approval) or spent its budget (suspended); the body is the run described below |
| 400 | message is empty |
| 403 | the conversation belongs to another user |
| 422 | a guard blocked the message, or the token budget was reached: {"error": "Token budget reached for this run."} |
| 429 | the 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."} |
| 500 | anything 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.
Pause for approval and finish later
Section titled “Pause for approval and finish later”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 phpclawandbin/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_budgetsteps ordurable_deadline_secondsseconds stops and is finished later. Withdurable_deadline_secondsunset, the console has no deadline, becausemax_execution_timeis0there. - Nothing finishes stopped runs by itself. Add
bin/console phpclaw:runs resume-dueto cron, every minute. It also restarts a run leftrunningby a process that died, once its last save is 900 seconds old. - When a run started by
bin/console phpclawspends its budget, the command prints the run id andbin/console phpclaw:runs resume <run>, and exits0.
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:
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.
Conversations and ownership
Section titled “Conversations and ownership”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:
security: role_hierarchy: ROLE_ADMIN: [ROLE_PHPCLAW_MANAGE_ALL]Using the agent in code
Section titled “Using the agent in code”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.
Queued jobs
Section titled “Queued jobs”Install Messenger and route the message to a transport:
framework: messenger: transports: async: '%env(MESSENGER_TRANSPORT_DSN)%' routing: 'PhpClaw\Symfony\Queue\RunAgentMessage': asyncuse PhpClaw\Symfony\Queue\QueueManager;
$jobId = $queue->dispatchSend('Generate the monthly report.');
$result = $queue->pollResult($jobId);bin/console messenger:consume asyncdispatchSend() 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.
Events
Section titled “Events”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.
Extending at boot
Section titled “Extending at boot”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; }}Guards and hooks from config
Section titled “Guards and hooks from config”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.
Web Profiler panel
Section titled “Web Profiler panel”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, andname=valueorname: valuepairs whose name containskey,token,secret,passwordorpasswd, 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 asapi_keyis shown as is - each guard block: the reason
The panel reads the bridged events, so turning event_bridge off empties it.