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.
Install
Section titled “Install”| Requirement | Version |
|---|---|
| PHP | 8.1 or later |
| Laravel | 10, 11, 12 or 13 |
composer require phpclaw/phpclaw-laravelLaravel 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-...php artisan migratephp 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.

Configuration
Section titled “Configuration”Publish the config file only when you want to edit it:
php artisan vendor:publish --tag=phpclaw-configThe other publish tags are phpclaw-routes and phpclaw-migrations.
| Key | Env variable | Default |
|---|---|---|
api_key | see below | first provider key found |
provider | PHPCLAW_PROVIDER | '', detected from your keys |
model | PHPCLAW_MODEL | '', the provider’s default |
base_url | PHPCLAW_BASE_URL | '' |
store_messages | PHPCLAW_STORE_MESSAGES | true |
max_iterations | PHPCLAW_MAX_ITERATIONS | 20, held between 1 and 50 |
memory_driver | PHPCLAW_MEMORY_DRIVER | database |
workspace_root | PHPCLAW_WORKSPACE | storage_path('phpclaw') |
system_prompt | PHPCLAW_SYSTEM_PROMPT | '', cut to 8000 characters |
max_tokens | PHPCLAW_MAX_TOKENS | 0, the provider’s default |
prompt_cache | PHPCLAW_PROMPT_CACHE | false |
thinking_budget | PHPCLAW_THINKING_BUDGET | 0, off |
tool_deny | PHPCLAW_TOOL_DENY, comma separated | [] |
max_tools_per_turn | PHPCLAW_MAX_TOOLS_PER_TURN | 0, the automatic per-turn limit; -1 or lower sends every tool |
always_include_tools | PHPCLAW_ALWAYS_INCLUDE_TOOLS, comma separated | []; offered on every turn, outside the limit |
shell_allowlist | none | core’s default allowlist |
tools | none | [] |
guards | none | [] |
hooks | none | [] |
skills | none | [] |
remote_skill_urls | PHPCLAW_REMOTE_SKILL_URLS, comma separated | [], only https:// URLs are kept, at most 50 are loaded |
events.bridge | PHPCLAW_EVENTS_BRIDGE | true |
telescope | PHPCLAW_TELESCOPE | true |
api.enabled | PHPCLAW_API_ENABLED | true |
api.prefix | PHPCLAW_API_PREFIX | phpclaw |
api.middleware | none | ['api', 'auth:sanctum'] |
api.throttle | PHPCLAW_API_THROTTLE | 60,1 |
admin_ids | PHPCLAW_ADMIN_IDS, comma separated | [] |
worker_commands | PHPCLAW_WORKER_COMMANDS, comma separated | [] |
cloud_key | PHPCLAW_CLOUD_KEY | '' |
cloud_signing_secret | PHPCLAW_CLOUD_SIGNING_SECRET | '' |
cloud_disable | PHPCLAW_CLOUD_DISABLE, comma separated | [] |
fallback_provider | PHPCLAW_FALLBACK_PROVIDER | '', off |
fallback_model | PHPCLAW_FALLBACK_MODEL | '', the fallback provider’s default |
fallback_api_key | PHPCLAW_FALLBACK_API_KEY | '' |
rate_limit_rpm | PHPCLAW_RATE_LIMIT_RPM | 0, off; held between 0 and 600 |
response_cache | PHPCLAW_RESPONSE_CACHE | false |
response_cache_ttl | PHPCLAW_RESPONSE_CACHE_TTL | 3600, held between 60 and 86400 |
max_token_budget | PHPCLAW_MAX_TOKEN_BUDGET | 0, off |
durable_runs | PHPCLAW_DURABLE_RUNS | false |
durable_step_budget | PHPCLAW_DURABLE_STEP_BUDGET | 0, no step limit |
durable_deadline_seconds | PHPCLAW_DURABLE_DEADLINE_SECONDS | unset, 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.
The API key
Section titled “The API key”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.
A custom endpoint
Section titled “A custom endpoint”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.
What 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; 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 name | Class | Who may call it |
|---|---|---|
db_query | PhpClaw\Laravel\Tools\DatabaseTool | phpclaw.manage-all |
read_log | PhpClaw\Laravel\Tools\LogTool | phpclaw.chat |
route_list | PhpClaw\Laravel\Tools\RouteListTool | phpclaw.chat |
config_get | PhpClaw\Laravel\Tools\ConfigGetTool | phpclaw.chat |
cache_inspect | PhpClaw\Laravel\Tools\CacheInspectTool | phpclaw.chat |
queue_status | PhpClaw\Laravel\Tools\QueueStatusTool | phpclaw.chat |
test_run | PhpClaw\Laravel\Tools\TestRunTool | phpclaw.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.
Denying tools
Section titled “Denying tools”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:systemWith the default tools, that leaves code_search and project_info.
Who may call a Laravel tool
Section titled “Who may call a Laravel tool”Each of the seven tools checks the caller before it runs:
phpclaw.chat: if your app defines a Gate ability namedphpclaw.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, andschedule:runstarted by cron all skip it. Only queue workers run the check:queue:work,queue:listen,horizon,horizon:work,horizon:supervisorand any command you add toworker_commands. Add a scheduled command there if it should be checked.
A refused call returns an error to the model naming the missing ability.
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; 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.
The manage-all ability
Section titled “The manage-all ability”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,42Artisan commands
Section titled “Artisan commands”Nine commands ship:
| Command | What it does |
|---|---|
php artisan phpclaw "message" | Send a message. Options: --stream, --provider=, --model=, --conv-id=, --continue, --trace |
php artisan phpclaw:chat | Open an interactive session in one conversation. Options: --conv-id=, --continue, --provider=, --model= |
php artisan phpclaw:about | Show the provider, tools, guards, memory, skills and cloud state. --test also sends a test prompt |
php artisan 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 |
php artisan phpclaw:stats | Count conversations, messages, and conversations updated in the last 24 hours |
php artisan phpclaw:jobs:list | List stored results of queued agent jobs |
php artisan phpclaw:jobs:status {jobId} | Show one queued job’s status and result |
php artisan phpclaw:mcp-server | Start 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.
One-shot options and the chat session
Section titled “One-shot options and the chat session”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:
| 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 |
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.
REST API
Section titled “REST API”Two routes are registered while api.enabled is true, under api.prefix, and three more when
durable_runs is also on:
| 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 |
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?"}'Request
Section titled “Request”| Field | Rule |
|---|---|
message | a string of at most 50,000 characters; an empty message returns 400; a missing, non-string or longer one fails validation |
conversation_id | optional, 26 letters and digits; continues that conversation; any other value fails validation |
Middleware
Section titled “Middleware”Every route runs this stack, in order:
phpclaw.json, which setsAccept: application/jsonon the request- your
api.middleware, by defaultapiandauth:sanctum throttle:with yourapi.throttle, by default 60 requests a minutephpclaw.api, which returns401 {"error":"Unauthenticated."}when no user is logged inphpclaw.owns, which returns403whenconversation_idbelongs 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.
Responses
Section titled “Responses”send returns:
{ "text": "...", "tool_calls": [{ "tool_name": "...", "tool_input": {}, "tool_result": "..." }], "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 below |
| 400 | the message is empty |
| 401 | no user is logged in |
| 403 | the conversation belongs to another user |
| 422 | a 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 |
| 429 | the 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 |
| 500 | anything 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.
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:
- 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 phpclawstill asks, and a command with no terminal, such as one started by cron, still refuses the change.phpclaw:runsis the exception: a run it resumes pauses again at the next such tool. - When a run started by
php artisan phpclawspends its budget, the command prints the run id andphp artisan phpclaw:runs resume <run>, and exits0. - Every chat run is saved after each step. A run that uses up
durable_step_budgetsteps ordurable_deadline_secondsseconds stops and is finished later. - The scheduler runs
phpclaw:runs resume-dueevery minute, so add Laravel’sschedule:runto 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:
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.
Conversations and ownership
Section titled “Conversations and ownership”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.
Using the agent in code
Section titled “Using the agent in code”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.
Memory drivers
Section titled “Memory drivers”| Driver | Stores |
|---|---|
database (default) | conversations in phpclaw_conversations and phpclaw_messages, everything else in phpclaw_memory |
database_conversation | conversation tables only |
database_kv | phpclaw_memory only |
cache | Laravel’s cache |
redis | Redis, through core’s RedisMemory |
file | JSON 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 |
array | the 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.
Queued jobs
Section titled “Queued jobs”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.
Events
Section titled “Events”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.
Extending at boot
Section titled “Extending at boot”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 and hooks from config
Section titled “Guards and hooks from config”'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.
Telescope
Section titled “Telescope”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:
name | Fields |
|---|---|
phpclaw_agent_run | run id, provider, model, iterations, duration, tools called |
phpclaw_provider_call | run id, provider, model, input, output and total tokens of one model call |
phpclaw_tool_call | run id, tool name, iteration, tool input |
phpclaw_guard_blocked | the 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.