develate / claudecode-cli-php
A resilient PHP SDK for controlling the Claude Code CLI.
Requires
- php: ^8.2
- symfony/process: ^7.2
Requires (Dev)
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A small, resilient PHP SDK for controlling the Claude Code CLI. The package follows Claude Code's native model:
Claude → Session → Run → Messages / Events → Result
It uses one claude -p --output-format stream-json --verbose process per query. Follow-up queries resume the session by ID; no persistent child process is required.
Requirements
- PHP 8.2 or newer
- Claude Code installed and authenticated
claudeavailable onPATH, or an absolute binary path
composer require develate/claudecode-cli-php
Query a session
A session is described by one SessionOptions object, a run by one RunOptions object. Session options are re-sent on every start, resume and fork, because Claude Code does not remember them across processes.
use Develate\ClaudecodeCli\Claude; use Develate\ClaudecodeCli\RunOptions; use Develate\ClaudecodeCli\SessionOptions; use Develate\ClaudecodeCli\Value\Effort; use Develate\ClaudecodeCli\Value\PermissionMode; $claude = new Claude(); $session = $claude->session(new SessionOptions( cwd: '/var/www/app', model: 'sonnet', effort: Effort::High, permissionMode: PermissionMode::AcceptEdits, tools: ['Read', 'Edit', 'Bash'], allowedTools: ['Read', 'Edit', 'Bash(composer *)'], disallowedTools: ['Bash(rm *)'], )); $result = $session->query('Fix the failing tests.', new RunOptions( maxTurns: 10, maxBudgetUsd: 2.00, )); echo $result->text; echo $result->usage->inputTokens; echo $result->usage->outputTokens; echo $result->estimatedCostUsd;
After the first query, $session->id() contains Claude's session ID. Later queries automatically use --resume:
$session->query('Now refactor the implementation.');
Resume or branch explicitly:
$continued = $claude->resume($sessionId, $options); $continued->query('Continue.'); $branch = $continued->fork(); $branch->query('Try a completely different implementation.');
A fork has no ID until Claude creates its new session. The original session remains unchanged. $session->with($options) keeps the conversation and changes only what the next run is told about it.
Process environment
One environment applies to --version, auth status and every run, so a host managing several accounts can never report one identity and run as another. A false value removes an inherited variable.
$claude = new Claude( binary: '/opt/homebrew/bin/claude', env: [ 'CLAUDE_CONFIG_DIR' => '/var/lib/app/accounts/one', 'ANTHROPIC_API_KEY' => false, ], ); $status = $claude->authStatus(); $status->loggedIn; $status->authMethod; // claudeai, console, apiKey, none, … $status->isOauth(); $status->email; $status->raw(); // everything the CLI reported
authStatus() reads the JSON body even when the command exits non-zero: "not signed in" is an answer, not a failure.
Session-limit reset (/limit-reset)
Claude Code's /limit-reset resets the session (5-hour) limit once per week; the reset usage still counts toward the weekly limit. The command itself is interactive-only, but both of its behaviors are plain OAuth API calls under the account's own token, so a host can offer the same feature without a TTY:
use Develate\ClaudecodeCli\LimitResetClient; $resets = new LimitResetClient($oauthAccessToken); $status = $resets->status(); // ?LimitResetStatus; null means "unknown", not "ineligible" if ($status?->redeemable()) { $result = $resets->reset($organizationUuid); // from GET /api/oauth/profile → organization.uuid $result->didReset(); // true on 'reset' $result->spent(); // true on 'already_used'; see $result->nextAvailableAt }
status() reads the juniper_tide block of GET /api/oauth/usage?at_wall=1&skip_spend=1 — the plain usage endpoint answers "juniper_tide": null, so only the at_wall variant is a real signal. reset() claims the reset via POST /api/organizations/{uuid}/reset_rate_limits with {"program": "juniper_tide"}. Outcomes are reset, already_used, not_limited, ineligible, and unavailable; unknown values fall back to unavailable, as Claude Code itself does.
A 401/403 throws LimitResetFailed with isAuthenticationError() === true: the token is stale or misses the profile scope, and retrying with the same token cannot succeed. The default sender is plain PHP streams (proxy-aware via the standard *_PROXY/NO_PROXY environment); pass a $http callable to override it, e.g. in tests.
Banked resets
Banked resets use Claude Code's separate cedar_ember program. A grant may
contain several resets; only the server-selected grant can currently be used.
$resets = new LimitResetClient($oauthAccessToken, cliVersion: $claude->version()); $status = $resets->bankedStatus(); // null means availability is unknown $status?->availableCount(); $status?->summary(); // available_count and grant expiry details foreach ($status?->grants ?? [] as $grant) { $creditId = $grant->creditId(); // changes after each reset in this grant if ($status->redeemableGrant($creditId) !== null) { $result = $resets->consumeBankedReset($organizationUuid, $grant->id, $requestId); $result->didReset(); $result->resetsLeft; $result->cleared; break; } }
Read availability before offering a reset, then confirm with the user before
consuming it. Persist the request ID and reuse it after transport failures or
an unconfirmed outcome. A confirmed rejection (not_limited, cooldown, or
ineligible) permits a later claim with a new request ID.
bankedStatus() reads /api/oauth/usage?cedar_ember=1&skip_spend=1 and never
spends a reset. consumeBankedReset() posts program, grant_id, and
request_id to the organization's reset_rate_limits endpoint. Grant
restrictions, pauses, and cooldowns remain separate from the weekly
session-limit reset.
Streaming
Streaming is the primitive; query() creates a Run and drains it.
use Develate\ClaudecodeCli\Event\TextDelta; use Develate\ClaudecodeCli\Event\ThinkingDelta; use Develate\ClaudecodeCli\Tool\ToolUse; $run = $session->stream('Explain this repository.'); foreach ($run as $item) { if ($item instanceof TextDelta) { echo $item->text; } if ($item instanceof ThinkingDelta) { // Reasoning, deliberately a different type from the answer. } if ($item instanceof ToolUse && $item->name === 'Bash') { echo '$ '.($item->input['command'] ?? '').PHP_EOL; } } $result = $run->result();
A run yields Message, Event, ToolUse, and ToolResult objects. Messages and events are deliberately separate concepts. --include-partial-messages is enabled by default, so API text and thinking deltas become TextDelta and ThinkingDelta events.
Calling $run->cancel() asks the running child process to stop. A cancelled run throws ProcessCancelled.
A result that leaves background commands running does not end the run: Claude Code reports each finished command to the model, which answers again in the same run. RunOptions::$maxBackgroundWait limits how many seconds a result waits for them. Past it the process is stopped, which also stops the commands, and the run returns the results it already has.
The prompt is written to the child process' stdin rather than passed as a trailing argument: several of Claude Code's flags are variadic and would swallow a positional prompt, and stdin also removes the operating system's argument-length ceiling.
Steering
While a run is streaming, $run->steer('Focus on the failing tests') adds a
user message to it and returns the message uuid. Pass image paths with
images: [...], and your own uuid with uuid: to correlate the message.
Claude Code hands it to the model at the next tool call; when no tool call is
left, it answers the message as a follow-up in the same run, which then ends
with that follow-up's result. A run that has not started or has finished
rejects steering with LogicException; $run->isSteerable() tells which.
Once the model has the message, the run yields it back as a UserMessage
with isReplay === true and the same uuid. Treat that, not the steer()
call, as delivery: a run cancelled or killed before then never showed the
message to the model. Stdin stays open until every steered message has been
replayed and answered.
For input arriving from a UI or another process, register
$run->onTick($callback) before iterating. It runs between reads while the
process is up, at most every 250 ms and also during silent reasoning; read your pending input
there and call steer() for each message. An exception thrown by the callback
ends the run, so catch delivery failures inside it. onTick(null) removes it.
$run = $session->stream('Refactor the billing module.'); $run->onTick(function () use ($run, $inbox): void { foreach ($inbox->takePending() as $message) { $inbox->markSent($message, $run->steer($message->text)); } }); foreach ($run as $item) { if ($item instanceof UserMessage && $item->isReplay) { $inbox->markDelivered($item->uuid); } }
Messages and content blocks
Known messages are represented as SystemMessage, AssistantMessage, UserMessage, and ResultMessage. Assistant content remains block-based:
foreach ($result->messages() as $message) { if ($message instanceof AssistantMessage) { echo $message->text(); foreach ($message->toolUses() as $toolUseBlock) { // Native tool-use content block. } } }
The V1 content types are TextBlock, ThinkingBlock, ToolUseBlock, and UnknownBlock.
Every protocol object exposes its complete source envelope or block through raw(). New top-level types and content blocks become UnknownMessage, UnknownEvent, or UnknownBlock; extra fields never cause parsing to fail. Only malformed JSON or a broken process is exceptional.
Session metadata
$result->init() is the system/init envelope Claude Code emits before its first message — the only place a run states what it actually resolved:
$init = $result->init(); $init?->model; // the model behind an alias $init?->tools; // the tools that survived the flags $init?->mcpServers; // the servers that connected $init?->permissionMode; $init?->apiKeySource; $init?->fastModeDisabledReason;
Result views
ToolUse is the protocol-level source of truth. Commands and file changes are convenience views:
foreach ($result->toolUses() as $toolUse) { // Every native tool invocation. } foreach ($result->commands() as $command) { printf("$ %s\n%s\n", $command->command, $command->output ?? ''); } foreach ($result->fileChanges() as $change) { echo $change->kind->value.': '.$change->path.PHP_EOL; } foreach ($result->thinking() as $thinking) { echo $thinking->thinking; }
Only Edit and Write tool calls become file changes. V1 does not guess whether a Write added or replaced a file.
Other result data includes:
- aggregate
Usageand per-modelModelUsage - estimated cost, intentionally named
estimatedCostUsd - structured output and permission denials
- stop reason, terminal reason, and a normalized
ResultStatus - rate-limit information when the CLI emits it
- requested model, reported models, Claude version, and working directory
- main-agent and subagent message views based on
parent_tool_use_id
Expected terminal results such as max-turns and max-budget are returned as normal Result objects. A non-zero process exit without a terminal result message throws ProcessFailed.
Structured output and images
$result = $session->query('Review this implementation.', new RunOptions( schema: [ 'type' => 'object', 'properties' => [ 'approved' => ['type' => 'boolean'], 'issues' => ['type' => 'array', 'items' => ['type' => 'string']], ], 'required' => ['approved', 'issues'], ], images: ['/tmp/reference.png'], )); $review = $result->structuredOutput;
The schema maps directly to --json-schema. In the V1 text-input transport, image paths are appended to the stdin prompt as explicit file references so Claude can inspect them with its file tools. The public images argument is transport-neutral and can later map to native multimodal blocks without changing session APIs.
Keeping project configuration out of a run
A host that runs Claude Code against folders it did not write needs the project's own configuration to stay inert. SessionOptions distinguishes "unset" from "empty" for exactly this:
use Develate\ClaudecodeCli\SessionOptions; use Develate\ClaudecodeCli\Value\PermissionPrompts; new SessionOptions( cwd: '/var/www/app', tools: [], // [] disables every tool; null keeps the default set settingSources: [], // [] loads no settings file; null keeps the default settings: '{"fastMode":true}', // a settings file path or a settings JSON string mcpConfig: ['{"mcpServers":{"app":{"command":"…"}}}'], strictMcpConfig: true, // ignore every server that did not come from mcpConfig permissionPromptTool: 'mcp__app_permissions__approve', permissionPrompts: PermissionPrompts::Host, appendSystemPrompt: 'Project notes.', chrome: false, // false disables the integration; null leaves it alone disableSlashCommands: true, // drop every skill, including those a project ships forwardSubagentText: true, );
settingSources: [] is sent as an empty --setting-sources rather than omitted — that is the only way to load none of the user, project and local settings files.
Configuration
$claude->version(); $projectClaude = $claude->in('/var/www/app'); $unsafeClaude = $claude->dangerouslyBypassPermissions();
Every start, resume and fork sends --permission-mode explicitly. The default SessionOptions::$permissionMode is PermissionMode::Default, which sends --permission-mode default; it does not defer to defaults that vary by CLI version, provider or telemetry settings.
Bypass mode is deliberately named as dangerous, and it never overrides a permission mode the caller set explicitly. Tool availability (tools), pre-approval (allowedTools), and denial (disallowedTools) remain separate.
Exceptions
ClaudeNotFoundProcessFailedProcessTimedOutProcessCancelledInvalidStreamJsonUnsupportedFeature
All extend ClaudeException.
V1 boundary
V1 intentionally does not include a persistent bidirectional process, interrupts with protocol acknowledgements, runtime model or permission changes, in-process permission callbacks (use permissionPromptTool and answer over MCP), MCP runtime control, hooks, plugin management, or a pricing database. LimitResetClient exposes session-limit resets and banked resets through the account's OAuth API.
Tests
composer test