SDKs
PHP SDK
The planned PHP SDK (spacefix/sdk): what it will look like, where it can run, and what to use until it ships.
This page previews the planned API so you can see how a PHP agent will feel. Nothing below can be installed yet.
Where it can run
An agent holds one HTTP connection open for as long as it lives, often days. PHP's classic deployment (PHP-FPM or mod_php, one process per request) is built to do the opposite, so the PHP SDK needs a long-running worker process, not a request-per-process web deployment.
The agent needs a process that stays alive between calls, can read the connection and run handler code at the same time, and receives SIGTERM and gets to finish. It cannot run inside a web request: a controller that connected would hold a worker forever, die at the time limit, and register tools that vanish the moment it exits. The SDK is designed to refuse to connect under a web SAPI (php-fpm, apache2handler, cgi-fcgi) and throw UnsupportedEnvironmentException.
| Shape | What it is |
|---|---|
| CLI daemon (recommended) | php bin/svc-agent, run by systemd, Supervisor, Docker or Kubernetes, as its own process type next to your web tier |
| Swoole or OpenSwoole worker | The same daemon started with the Swoole transport; good if your app already runs on Swoole |
| RoadRunner service | RoadRunner runs the daemon under its service plugin. The agent is not a RoadRunner HTTP worker |
| Laravel Octane, alongside | Octane serves HTTP; a separate php artisan svc:agent process runs next to it. Do not start the agent inside an Octane worker |
| Not supported | PHP-FPM, mod_php, CGI, serverless (Lambda in web mode, Vercel PHP), shared hosting, short-lived cron jobs |
Until the SDK ships, the same constraint applies to anything you build yourself against the agent protocol.
What it will be
| Item | Planned value |
|---|---|
| Composer package | spacefix/sdk (namespace Spacefix\Svc) |
| PHP | 8.3 and newer |
| Extensions | json, mbstring. Optional: pcntl (signals), swoole or openswoole, ev or uv |
| Engines | Amp 3 (default, fibers), ReactPHP, or Swoole coroutines |
| Companion packages | spacefix/sdk-laravel, spacefix/sdk-symfony, spacefix/sdk-testing |
| Core dependencies | psr/log, plus the chosen engine's HTTP client |
Like every SDK, it connects out to the gateway (no inbound port), registers your tools, resources and prompts, runs your handlers when calls arrive, reconnects with backoff, and can report telemetry. See the SDK overview.
Install
composer require spacefix/sdk
composer require amphp/http-client # default engine; or react/http, or ext-swoole
composer require spacefix/sdk-laravel # optional
A minimal agent
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Spacefix\Svc\Agent;
use Spacefix\Svc\Attributes\{Tool, Param};
final class OrderTools
{
public function __construct(private OrderRepository $orders) {}
#[Tool(description: 'Look up a single order by its id. Returns null if it does not exist.')]
public function getOrder(#[Param('Order id, for example o_123')] string $id): ?array
{
return $this->orders->find($id);
}
}
$agent = Agent::create(
apiKey: getenv('SPACEFIX_API_KEY') ?: '', // the URL comes from SPACEFIX_SERVICE_URL
);
$agent->register(new OrderTools(new OrderRepository(/* ... */)));
// Connects, reconnects, and returns after SIGINT or SIGTERM.
exit($agent->run());
export SPACEFIX_API_KEY=agent_key_from_the_dashboard
export SPACEFIX_SERVICE_URL=https://api.spacefix.io # optional; defaults to the production URL
php bin/svc-agent
# [svc-sdk] connected with 1 tool(s)
run() is the blocking entry point for the CLI daemon: it starts the event loop, installs SIGINT and SIGTERM handlers (this needs pcntl), connects, and returns an exit code when the agent stops (0 after a clean shutdown).
Defining a tool
PHP 8 attributes describe tools; parameter types, readonly classes and backed enums feed a schema generator that reads them once at registration.
use Spacefix\Svc\Attributes\{Tool, Param};
enum Status: string { case Open = 'open'; case Shipped = 'shipped'; case Cancelled = 'cancelled'; }
final readonly class SearchArgs
{
public function __construct(
#[Param('Customer id, for example cus_123')] public string $customerId,
#[Param('Order status')] public Status $status = Status::Open,
#[Param('Maximum orders to return', min: 1, max: 100)] public int $limit = 20,
) {}
}
final class OrderTools
{
public function __construct(private OrderRepository $orders) {}
#[Tool(name: 'search_orders',
description: "Search a customer's orders by status. Returns at most `limit` orders, newest first.")]
public function search(SearchArgs $args): array
{
return $this->orders->search($args->customerId, $args->status, $args->limit);
}
}
A tool method takes either one class parameter (a readonly class whose constructor parameters are the properties) or several scalar and enum parameters. Names default to the method name in snake_case and must match ^[a-zA-Z0-9_-]{1,64}$. Resources and prompts use #[Resource] and #[Prompt]. Closures and raw JSON Schema are also supported:
$agent->tool(
name: 'get_order',
description: 'Look up a single order by id.',
handler: fn (string $id) => $orders->find($id),
params: ['id' => new Param('Order id')],
);
Configuration
$agent = Agent::create(
apiKey: '...', // required
url: 'https://api.spacefix.io', // optional
otel: Otel::options(argKeys: true), // optional; otel: false disables
);
| Option | Default | Meaning |
|---|---|---|
apiKey | none, required | The agent key. An empty value throws \InvalidArgumentException('apiKey is required.') |
url | SPACEFIX_SERVICE_URL, then https://api.spacefix.io | The gateway base URL. Set it per environment |
otel | on when open-telemetry/api is installed | false disables the bridge. argKeys: true also records argument names, never values |
The design also proposes transport, logger, maxConcurrentCalls, maxResultBytes, shutdownGrace, onStateChange and callScope.
Running in production
Run the daemon under a supervisor, as its own process type:
; /etc/supervisor/conf.d/svc-agent.conf
[program:svc-agent]
command=php /var/www/app/bin/svc-agent
numprocs=2 ; two processes, two connections (check your plan's connection limit)
process_name=%(program_name)s_%(process_num)02d
autorestart=true ; the SDK exits 75 to be recycled (maxCalls, maxMemory)
stopsignal=TERM
stopwaitsecs=30 ; above shutdownGrace
environment=SPACEFIX_API_KEY="%(ENV_SF_API_KEY)s"
FROM php:8.3-cli
RUN docker-php-ext-install pcntl
COPY . /var/www/app
CMD ["php", "/var/www/app/bin/svc-agent"] # run with `docker run --init` so SIGTERM reaches PHP
- Concurrency. Each call runs in its own fiber (or coroutine). With ordinary blocking database drivers, one process serves one call at a time, so scale with more processes (
numprocs). Each process is its own agent with its own connection, and the gateway spreads calls across them, so keep state in a database. - State leakage. A daemon is a long-lived PHP process, unlike the per-request model most PHP code assumes. Static properties, singletons, open database connections and ORM identity maps live for days. A
CallScopehook runs before and after each call so you can reset them, and the Laravel and Symfony packages wire the framework's own reset mechanisms into it. - Memory.
maxCallsandmaxMemorymake the daemon exit with code75so the supervisor recycles it before PHP runs out of memory. - Shutdown.
disconnect()is idempotent;run()waits up toshutdownGracefor running handlers. Under Docker the daemon must be PID 1 or run under an init (docker run --init) soSIGTERMarrives. - Health.
$agent->state()returnsState::Connecting,Connected,RetryingorStopped. A daemon has no HTTP port, so the CLI command can touch a file while connected (svc-agent --health-file=/run/svc-agent.ok) for an exec probe. Use it for readiness, not liveness. - Laravel.
spacefix/sdk-laraveladds a service provider, aconfig/svc.phpfile and an Artisan command,php artisan svc:agent, which you run under Supervisor, never inside a request or a queue job. A Symfony bundle providesphp bin/console svc:agentthe same way.
Telemetry
An optional package reports process health to the Agents page, the counterpart of @spacefix/telemetry:
use Spacefix\Svc\Telemetry\Telemetry;
$agent->use(new Telemetry()); // every 10 seconds over the agent's connection
$agent->use(new Telemetry(intervalSeconds: 30, report: fn (array $m) => $statsd->gauge('heap', $m['heapUsed'])));
| Option | Default | Meaning |
|---|---|---|
intervalSeconds | 10 | How often to sample and report |
report | built in | A callable that receives each sample; replaces the gateway transport |
otel | true | Also publish through OpenTelemetry |
OpenTelemetry
The bridge uses only the OpenTelemetry API (open-telemetry/api). It is a no-op until an OpenTelemetry SDK is registered, and it is off when the API package is not installed. Each call that reaches your agent produces a server span (tools/call <name>, resources/read <uri> or prompts/get <name>) that continues the caller's trace, plus the duration histogram mcp.server.operation.duration, the counter spacefix.agent.reconnects, the gauge spacefix.agent.connected and connection logs. Argument values and results are never recorded. PHP's OpenTelemetry metrics exporters are less mature than other languages', so spans and logs are the dependable signals. The attributes and metric names are the same in every SDK; see OpenTelemetry.
Next steps
- Node.js SDK and Go SDK: available now
- The agent protocol: connect from PHP today without an SDK
- SDK overview: every language and its status