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.
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 and
PHPCLAW_CLOUD_SIGNING_SECRET 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 |
tools | [] |
tool_deny | [] |
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 | [] |
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.
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 | 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, 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.
Creating the tables
Section titled “Creating the tables”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:
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 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 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 |
phpclaw: tools: - PhpClaw\Symfony\Tools\LogTool - PhpClaw\Symfony\Tools\DatabaseTooldb_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, project_info and file_edit.
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 ...", "remedy": "Run this request synchronously rather than through the queue." }}Approval and PHP files
Section titled “Approval and PHP files”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.
Console commands
Section titled “Console commands”Seven commands ship:
| Command | What it does |
|---|---|
bin/console phpclaw "message" | Send a message. --stream prints tokens as they arrive |
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: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.
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 |
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, 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 |
|---|---|
| 400 | message is empty |
| 403 | the conversation belongs to another user |
| 422 | a guard blocked the message |
| 500 | anything else failed; no detail is returned |
chat/stream sends the events tool_before, tool_after, chunk (with text), done and
error.
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 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. 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, input, output and total tokens, 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.