Skip to content

phpClaw for Laravel

The Laravel adapter registers phpClaw as a service provider. It adds Artisan commands, two REST routes (three more with durable runs on), database memory, queued agent jobs, and seven Laravel tools you can switch on.


RequirementVersion
PHP8.1 or later
Laravel10, 11, 12 or 13
Terminal window
composer require phpclaw/phpclaw-laravel

Laravel discovers the service provider PhpClaw\Laravel\PhpClawServiceProvider and the PhpClaw facade alias on install.

Set one provider key in .env, then create the tables:

ANTHROPIC_API_KEY=sk-ant-...
Terminal window
php artisan migrate
php artisan phpclaw "how many users signed up this week?"

The migrations load from the package, so migrate creates phpclaw_conversations, phpclaw_messages and phpclaw_memory without publishing anything.

phpClaw in a Laravel app


Publish the config file only when you want to edit it:

Terminal window
php artisan vendor:publish --tag=phpclaw-config

The other publish tags are phpclaw-routes and phpclaw-migrations.

KeyEnv variableDefault
api_keysee belowfirst provider key found
providerPHPCLAW_PROVIDER'', detected from your keys
modelPHPCLAW_MODEL'', the provider’s default
base_urlPHPCLAW_BASE_URL''
store_messagesPHPCLAW_STORE_MESSAGEStrue
max_iterationsPHPCLAW_MAX_ITERATIONS20, held between 1 and 50
memory_driverPHPCLAW_MEMORY_DRIVERdatabase
workspace_rootPHPCLAW_WORKSPACEstorage_path('phpclaw')
system_promptPHPCLAW_SYSTEM_PROMPT'', cut to 8000 characters
max_tokensPHPCLAW_MAX_TOKENS0, the provider’s default
prompt_cachePHPCLAW_PROMPT_CACHEfalse
thinking_budgetPHPCLAW_THINKING_BUDGET0, off
tool_denyPHPCLAW_TOOL_DENY, comma separated[]
max_tools_per_turnPHPCLAW_MAX_TOOLS_PER_TURN0, the automatic per-turn limit; -1 or lower sends every tool
always_include_toolsPHPCLAW_ALWAYS_INCLUDE_TOOLS, comma separated[]; offered on every turn, outside the limit
shell_allowlistnonecore’s default allowlist
toolsnone[]
guardsnone[]
hooksnone[]
skillsnone[]
remote_skill_urlsPHPCLAW_REMOTE_SKILL_URLS, comma separated[], only https:// URLs are kept, at most 50 are loaded
events.bridgePHPCLAW_EVENTS_BRIDGEtrue
telescopePHPCLAW_TELESCOPEtrue
api.enabledPHPCLAW_API_ENABLEDtrue
api.prefixPHPCLAW_API_PREFIXphpclaw
api.middlewarenone['api', 'auth:sanctum']
api.throttlePHPCLAW_API_THROTTLE60,1
admin_idsPHPCLAW_ADMIN_IDS, comma separated[]
worker_commandsPHPCLAW_WORKER_COMMANDS, comma separated[]
cloud_keyPHPCLAW_CLOUD_KEY''
cloud_signing_secretPHPCLAW_CLOUD_SIGNING_SECRET''
cloud_disablePHPCLAW_CLOUD_DISABLE, comma separated[]
fallback_providerPHPCLAW_FALLBACK_PROVIDER'', off
fallback_modelPHPCLAW_FALLBACK_MODEL'', the fallback provider’s default
fallback_api_keyPHPCLAW_FALLBACK_API_KEY''
rate_limit_rpmPHPCLAW_RATE_LIMIT_RPM0, off; held between 0 and 600
response_cachePHPCLAW_RESPONSE_CACHEfalse
response_cache_ttlPHPCLAW_RESPONSE_CACHE_TTL3600, held between 60 and 86400
max_token_budgetPHPCLAW_MAX_TOKEN_BUDGET0, off
durable_runsPHPCLAW_DURABLE_RUNSfalse
durable_step_budgetPHPCLAW_DURABLE_STEP_BUDGET0, no step limit
durable_deadline_secondsPHPCLAW_DURABLE_DEADLINE_SECONDSunset, half of max_execution_time, or no limit when that is 0, as on the command line

The seven keys from fallback_provider to max_token_budget switch on the building blocks. A fallback_provider that is custom, or that does not share the main provider’s tool format, is skipped with a warning in your log. The rate limit and the response cache use your app’s default cache store, so the limit is shared by every request and worker that uses that store. The three durable_* keys are covered in Pause for approval and finish later.

api_key has no variable of its own. It takes the first non-empty value of ANTHROPIC_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, GEMINI_API_KEY, MISTRAL_API_KEY and DEEPSEEK_API_KEY, in that order.

Set PHPCLAW_PROVIDER=custom and PHPCLAW_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. See Providers.

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


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; project_info inspects the app’s base_path(), whatever folder the process started in, so it also works over MCP. See Tools.

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

'tools' => [
\PhpClaw\Tools\ShellTool::class,
\PhpClaw\Tools\HttpTool::class,
\PhpClaw\Tools\FileWriteTool::class,
\PhpClaw\Tools\FileEditTool::class,
],

ShellTool is built with shell_allowlist, and FileReadTool, FileWriteTool and FileEditTool with workspace_root. Every other class, HttpTool included, is resolved from the container. The shell allowlist defaults to ls, pwd, df, wc, date, uptime, hostname and whoami, and it matches the first word of the command exactly, case included.

The seven Laravel tools are not registered until you list them in tools in config/phpclaw.php:

Tool nameClassWho may call it
db_queryPhpClaw\Laravel\Tools\DatabaseToolphpclaw.manage-all
read_logPhpClaw\Laravel\Tools\LogToolphpclaw.chat
route_listPhpClaw\Laravel\Tools\RouteListToolphpclaw.chat
config_getPhpClaw\Laravel\Tools\ConfigGetToolphpclaw.chat
cache_inspectPhpClaw\Laravel\Tools\CacheInspectToolphpclaw.chat
queue_statusPhpClaw\Laravel\Tools\QueueStatusToolphpclaw.chat
test_runPhpClaw\Laravel\Tools\TestRunToolphpclaw.chat

config_get returns one scalar value by dot-notation key, never a whole array; true, false and numbers keep their JSON type. It returns ***withheld*** instead when the key starts with database. or logging.channels.slack., or when any segment of the key contains one of 27 words: password, passwd, secret, token, key, api_key, dsn, webhook, cipher, salt, passphrase, private, credential, cert, signature, hash, nonce, auth, pwd, bearer, access, cred, license, pin, otp, seed and jwt. A URL value has any user:password@ replaced. A secret stored under a key with none of those words, such as app.foo, is returned.

test_run runs one fixed command, php artisan test, with an optional --filter that may hold only letters, digits, _, \ and :. It runs only from an Artisan command in a terminal, is refused when APP_ENV is production, stops after 600 seconds, and you approve every run.

'tools' => [
\PhpClaw\Laravel\Tools\DatabaseTool::class,
\PhpClaw\Laravel\Tools\LogTool::class,
\App\Tools\MyTool::class,
],

Any class in tools other than ShellTool, FileReadTool, FileWriteTool and FileEditTool is resolved from the container, so your own tools can take constructor dependencies. A class that does not exist is skipped.

tool_deny removes tools by name or by group. group:system holds db_query, read_log, http_request, file_read, file_write, shell_exec, route_list, config_get, cache_inspect and queue_status. group:content and group:admin are defined but empty, so denying them removes nothing.

PHPCLAW_TOOL_DENY=group:system

With the default tools, that leaves code_search and project_info.

Each of the seven tools checks the caller before it runs:

  • phpclaw.chat: if your app defines a Gate ability named phpclaw.chat, the Gate decides. Otherwise any logged-in user passes, and a request with no user is refused.
  • phpclaw.manage-all: see the manage-all ability.
  • Artisan skips the check. When the agent runs inside any Artisan command, the tools run without it. That is every command, not only phpClaw’s: tinker, your own commands, and schedule:run started by cron all skip it. Only queue workers run the check: queue:work, queue:listen, horizon, horizon:work, horizon:supervisor and any command you add to worker_commands. Add a scheduled command there if it should be checked.

A refused call returns an error to the model naming the missing ability.

With durable_runs off, the default, every agent is built with CliApprovalGate. In a terminal you are asked before a tool changes something; in a web request or a queue worker there is nobody to ask, so the change is refused. See Security: approval.

In a web request or a queue worker that refusal covers every file_write and file_edit call, and any shell_exec command on the allowlist but outside core’s read-only command list, so those complete only from an Artisan command run in a terminal. With durable_runs on, a web request or queue worker pauses the run instead, and someone decides later: see Pause for approval and finish later.

A file_write listed in tools may create .php files when the agent runs inside an Artisan command that is not a queue worker, by the same rule as above. In web requests, queue workers and the MCP server it may not.


phpclaw.manage-all lets a user read every user’s conversations and queued jobs, approve or deny every user’s paused runs, and call db_query. A user holds it when either is true:

  • their id is in admin_ids, or
  • Gate::allows('phpclaw.manage-all') is true.

If your app has not defined the ability when phpClaw boots, the package defines it to deny everyone. Define it yourself to grant it:

use Illuminate\Support\Facades\Gate;
Gate::define('phpclaw.manage-all', fn ($user) => $user->is_admin);

Or list user ids:

PHPCLAW_ADMIN_IDS=1,42

Nine commands ship:

CommandWhat it does
php artisan phpclaw "message"Send a message. Options: --stream, --provider=, --model=, --conv-id=, --continue, --trace
php artisan phpclaw:chatOpen an interactive session in one conversation. Options: --conv-id=, --continue, --provider=, --model=
php artisan phpclaw:aboutShow the provider, tools, guards, memory, skills and cloud state. --test also sends a test prompt
php artisan 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
php artisan phpclaw:statsCount conversations, messages, and conversations updated in the last 24 hours
php artisan phpclaw:jobs:listList stored results of queued agent jobs
php artisan phpclaw:jobs:status {jobId}Show one queued job’s status and result
php artisan phpclaw:mcp-serverStart the MCP server. --transport=stdio (default) or --transport=http
php artisan phpclaw:runs {action}Durable runs: list, approve {run} {call}, deny {run} {call} --reason=, resume {run}, resume-due --limit=

phpclaw:stats counts the whole installation. The two jobs commands show only jobs you may see, the same as the queue API: from a terminal, with no user, that is jobs dispatched without a user.

No command reads, lists or deletes stored conversations.

After each reply php artisan phpclaw prints the provider, model, tokens, the tools that ran and the conversation id. Each turn, in phpclaw and in phpclaw:chat, stores its tool calls with the conversation as tool rows before the reply, and phpclaw:chat reloads the stored conversation after each turn so later turns keep them. --conv-id=<id> continues that stored conversation; an id with no stored conversation prints a warning and starts a new one, and another user’s id prints Conversation <id> is not available to this command and starts a new one. --continue picks the last conversation, whose id is kept in storage/phpclaw/.last-conversation. 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.

php artisan 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

Typing / opens a menu of these commands under the cursor that narrows as you type; on Windows the prompt is plain. exit or quit on its own line 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 php artisan phpclaw:chat --conv-id=<id> to resume. /model and /provider never write config or .env.

With APP_ENV=production and a workspace_root outside storage/phpclaw, such as PHPCLAW_WORKSPACE=./, both phpclaw:chat and --trace refuse to start.

Laravel’s own php artisan about also gains a phpClaw section. The MCP server is covered on the MCP page.


Two routes are registered while api.enabled is true, under api.prefix, and three more when durable_runs is also on:

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
Terminal window
curl -X POST "https://your-app.example/phpclaw/send" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"message":"how many users signed up this week?"}'
FieldRule
messagea string of at most 50,000 characters; an empty message returns 400; a missing, non-string or longer one fails validation
conversation_idoptional, 26 letters and digits; continues that conversation; any other value fails validation

Every route runs this stack, in order:

  1. phpclaw.json, which sets Accept: application/json on the request
  2. your api.middleware, by default api and auth:sanctum
  3. throttle: with your api.throttle, by default 60 requests a minute
  4. phpclaw.api, which returns 401 {"error":"Unauthenticated."} when no user is logged in
  5. phpclaw.owns, which returns 403 when conversation_id belongs to another user

phpclaw.api is part of the package, so the routes require a logged-in user even if a published config file changes api.middleware.

send returns:

{
"text": "...",
"tool_calls": [{ "tool_name": "...", "tool_input": {}, "tool_result": "..." }],
"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 below
400the message is empty
401no user is logged in
403the conversation belongs to another user
422a guard blocked the message, the token budget was reached ({"error": "Token budget reached for this run."}), or validation failed: message missing, not a string or over 50,000 characters, or a malformed conversation_id
429the rate_limit_rpm wait ran out: {"error": "Rate limit reached, try again shortly."}. Laravel’s api.throttle middleware also answers 429, with its own body
500anything else failed, including a provider that needs an API key and has none; the error is reported to your log, not returned

Both endpoints store the turn’s tool calls with the conversation as tool rows before the reply.

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:

  • In a web request or a queue worker, a tool that changes data pauses the run instead of being refused. Every other Artisan command keeps the Y/n prompt, so php artisan phpclaw still asks, and a command with no terminal, such as one started by cron, still refuses the change. phpclaw:runs is the exception: a run it resumes pauses again at the next such tool.
  • When a run started by php artisan phpclaw spends its budget, the command prints the run id and php artisan phpclaw:runs resume <run>, and exits 0.
  • Every chat 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.
  • The scheduler runs phpclaw:runs resume-due every minute, so add Laravel’s schedule:run to cron.

A stopped run is described as:

{
"run_id": "01M47S4NJXA92W9F4D1Q133PAH",
"status": "awaiting_approval",
"conversation_id": "01M47S4NJPVRA35P57QP7PNN31",
"pending": { "call_id": "call_7xmo6xnt", "tool_name": "file_write", "tool_input": { "content": "hello", "path": "smoke1.txt" } }
}

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 "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"call_id":"<call_id>"}'

deny takes call_id and an optional reason. A run belongs to the user who started its conversation: GET /phpclaw/runs lists only the caller’s runs, all of them for a manage-all user, 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 the id of the user who created it in phpclaw_conversations.user_id, a string of up to 180 characters. The id comes from Auth::id(), never from the request. Conversations created from Artisan have no user and store an empty string.

A user lists and opens only their own conversations. Opening another user’s conversation is refused, not returned empty. Users holding manage-all can see every conversation.

The rule lives in the memory driver, DatabaseConversationMemory, so every surface inherits it. It throws ConversationAccessDeniedException, and AbstractDatabaseMemory::guard() rethrows that exception rather than turning it into a generic memory error. The migration indexes (user_id, namespace, updated_at) as idx_user_namespace_updated.

Only the database, eloquent, database_conversation and eloquent_conversation drivers keep this rule. With any other memory, including a custom MemoryInterface binding, the REST API answers every request with 503 and logs a warning, so no conversation is served without an owner. Artisan commands are not affected.


The agent is a singleton. Resolve it by PhpClaw\Claw or PhpClaw\Contracts\ClawInterface, which give the same instance, or use the facade:

use PhpClaw\Laravel\Facades\PhpClaw;
echo PhpClaw::send('What is the queue depth?')->text;
PhpClaw::stream('Summarise recent orders.', function (string $token): void {
echo $token;
});
$turn1 = PhpClaw::sendInConversation(PhpClaw::conversation(), 'My name is Alice.');
$turn2 = PhpClaw::sendInConversation($turn1->conversation, 'What is my name?');
use Illuminate\Http\JsonResponse;
use PhpClaw\Contracts\ClawInterface;
final class HealthController extends Controller
{
public function __construct(private readonly ClawInterface $agent) {}
public function __invoke(): JsonResponse
{
return response()->json(['summary' => $this->agent->send('Summarise system health.')->text]);
}
}

In tests, PhpClaw::fake(['first reply', 'second reply']) swaps the agent for a fake that returns those texts in order and never calls a provider.


DriverStores
database (default)conversations in phpclaw_conversations and phpclaw_messages, everything else in phpclaw_memory
database_conversationconversation tables only
database_kvphpclaw_memory only
cacheLaravel’s cache
redisRedis, through core’s RedisMemory
fileJSON files in storage/phpclaw/memory under the app’s storage directory (storage_path()), the same path for artisan and web requests. The REST API refuses it, because it keeps no owner per conversation
arraythe current process only

eloquent, eloquent_conversation and eloquent_kv are other names for the three database drivers. The REST API needs one that keeps ownership: database, eloquent, database_conversation or eloquent_conversation. With store_messages off, the agent stores no message content, whichever driver you use. See Memory.


use PhpClaw\Laravel\QueueManager;
$queue = app(QueueManager::class);
$jobId = $queue->dispatchSend('Generate the monthly sales report.');
$result = $queue->pollResult($jobId);

dispatchSend() records the current user and returns a job id. Its second argument is how long the result is kept, in seconds, by default 3600. The job runs as that user, so the tool checks above apply to it.

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 4 for a suspended job. listJobs() and forgetJob() apply the same owner rule. The result, including the reply text, is stored in the configured memory driver even when store_messages is off. A job needs a queue worker and a queue connection other than sync to run in the background.


With events.bridge on, every phpClaw lifecycle event is also dispatched on Laravel’s event dispatcher as phpclaw. followed by the event name, with the event context array as the argument:

use Illuminate\Support\Facades\Event;
Event::listen('phpclaw.agent.after', function (array $ctx): void {
logger()->info('phpClaw run finished', ['duration_ms' => $ctx['duration_ms'] ?? null]);
});

The event names are listed on the Hooks page.

With events.bridge off, phpClaw’s own hooks still fire; only the Laravel dispatch stops.

While it boots, before registering guards, hooks, skills, memory drivers and providers, the provider dispatches phpclaw.booting with a PhpClaw\Laravel\Extension\PhpClawExtensions object. Add to its tools, guards, hooks, skills, memory and providers arrays from a listener to register them.

'guards' => [
['class' => \App\Guards\ProfanityGuard::class, 'priority' => 5],
],
'hooks' => [
['event' => 'agent.before', 'handler' => [\App\Listeners\AuditLog::class, 'handle'], 'priority' => 10],
],

priority defaults to 10. handler must be callable, such as a closure or a static method; an entry whose handler cannot be called is skipped. A guard class that does not exist or does not implement PhpClaw\Guards\Contracts\GuardInterface is skipped. See Guards and Hooks.


When Laravel Telescope is installed and telescope is on, phpClaw records four kinds of entry. Telescope lists them on its Events tab, each under its name:

nameFields
phpclaw_agent_runrun id, provider, model, iterations, duration, tools called
phpclaw_provider_callrun id, provider, model, input, output and total tokens of one model call
phpclaw_tool_callrun id, tool name, iteration, tool input
phpclaw_guard_blockedthe block reason

A run’s tokens are on its phpclaw_provider_call entries, one per model call, matched by run id. These entries never hold the message or the reply. Tool input they do hold. The entries are read from the bridged Laravel events, so turning events.bridge off also stops them.

Whenever Telescope is installed, even with telescope off, phpClaw adds a Telescope filter that drops the raw bridged phpclaw.* events, which carry the full context, message and reply included. Your own events, and Telescope’s other watchers, are untouched.