Skip to content

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.


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[]
shell_allowlistnonecore’s default allowlist
toolsnone[]
guardsnone[]
hooksnone[]
skillsnone[]
remote_skill_urlsPHPCLAW_REMOTE_SKILL_URLS, comma separated[], 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[]

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

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.

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, project_info and file_edit.

Each of the six 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.

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.


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,42

Seven commands ship:

CommandWhat it does
php artisan phpclaw "message"Send a message. Options: --stream, --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

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.


Two routes are registered while api.enabled is true, under api.prefix:

MethodRouteReturns
POST/phpclaw/sendthe reply as JSON
POST/phpclaw/chat/streamServer-Sent Events
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 longer one fails validation
conversation_idoptional, 26 letters and digits; continues that conversation; any other value fails validation

Both routes run 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
400the message is empty
401no user is logged in
403the conversation belongs to another user
422a guard blocked the message, or validation failed: message over 50,000 characters or a malformed conversation_id
500anything 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.


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.


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
arraythe 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.


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.


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.

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. 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 three entry types:

Entry typeFields
phpclaw_agent_runprovider, model, iterations, duration, input, output and total tokens, tools called
phpclaw_tool_calltool name, iteration, tool input
phpclaw_guard_blockedthe 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.