phpClaw for Laravel
The Laravel adapter registers phpClaw as a service provider. It adds Artisan commands, two REST routes, database memory, queued agent jobs, and six 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 | [] |
shell_allowlist | none | core’s default allowlist |
tools | none | [] |
guards | none | [] |
hooks | none | [] |
skills | none | [] |
remote_skill_urls | PHPCLAW_REMOTE_SKILL_URLS, comma separated | [], 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 | [] |
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 seven default tools: shell_exec, http_request,
file_read, file_write, file_edit, code_search and project_info. The shell tool uses
shell_allowlist, and the file tools work inside workspace_root. See Tools.
The six 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 |
config_get returns one scalar value by dot-notation key, never a whole array. 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.
'tools' => [ \PhpClaw\Laravel\Tools\DatabaseTool::class, \PhpClaw\Laravel\Tools\LogTool::class, \App\Tools\MyTool::class,],Any class in tools 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, project_info and file_edit.
Who may call a Laravel tool
Section titled “Who may call a Laravel tool”Each of the six 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”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.
file_write 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, 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”Seven commands ship:
| Command | What it does |
|---|---|
php artisan phpclaw "message" | Send a message. Options: --stream, --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 |
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.
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:
| Method | Route | Returns |
|---|---|---|
| POST | /phpclaw/send | the reply as JSON |
| POST | /phpclaw/chat/stream | Server-Sent Events |
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 longer one fails validation |
conversation_id | optional, 26 letters and digits; continues that conversation; any other value fails validation |
Middleware
Section titled “Middleware”Both routes run 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 |
|---|---|
| 400 | the message is empty |
| 401 | no user is logged in |
| 403 | the conversation belongs to another user |
| 422 | a guard blocked the message, or validation failed: message over 50,000 characters or a malformed conversation_id |
| 500 | anything else failed, including a provider that needs an API key and has none; the error is reported to your log, not 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 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.
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 |
array | the current process only |
eloquent, eloquent_conversation and eloquent_kv are other names for the three database
drivers. 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. 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', ['tokens' => $ctx['input_tokens'] ?? 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. 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 three entry types:
| Entry type | Fields |
|---|---|
phpclaw_agent_run | provider, model, iterations, duration, input, output and total tokens, tools called |
phpclaw_tool_call | tool name, iteration, tool input |
phpclaw_guard_blocked | the block reason |
The message and the reply are not recorded, whatever store_messages is set to. Tool input is. The entries are read from the bridged
Laravel events, so turning events.bridge off also stops them.