PHP Plugins
PHP serves the HTTP plugin transport: instead of stdio, the same protocol
speaks JSON-RPC over HTTP POST, so any language that can read and write JSON
can host a plugin and no SDK is needed. The contract is a handful of methods:
the handshake, function.call, and the object.* pair for classes.
The complete, commented example lives at
examples/plugins/php-server in the repository
(PHP 8, no dependencies); this page shows its core.
<?php
function respond(array $payload): never
{
header('Content-Type: application/json');
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
exit;
}
// Optional bearer enforcement: with a token in this process's environment,
// every request must carry it. The host side is --plugin-header
// "Authorization=Bearer <token>"; see Authentication below.
$token = getenv('PHPDEMO_TOKEN');
if ($token !== false && ($_SERVER['HTTP_AUTHORIZATION'] ?? '') !== "Bearer {$token}") {
http_response_code(401);
respond(['jsonrpc' => '2.0', 'id' => null,
'error' => ['code' => -32001, 'message' => 'missing or invalid bearer token']]);
}
$request = json_decode(file_get_contents('php://input') ?: '', true);
$method = $request['method'] ?? '';
switch ($method) {
case 'scriptling.handshake':
respond(['jsonrpc' => '2.0', 'id' => $request['id'], 'result' => [
'protocol' => '1.0',
'transport' => 'json',
'library' => [
'name' => 'hello',
'version' => '1.0.0',
'description' => 'PHP hello plugin',
],
'capabilities' => [],
'schema' => [
'functions' => [['name' => 'greet']],
'classes' => [], 'constants' => [],
],
]]);
case 'function.call':
$who = $request['params']['args'][0]['value'] ?? 'world';
respond(['jsonrpc' => '2.0', 'id' => $request['id'], 'result' => [
'type' => 'string',
'value' => "Hello, {$who}",
]]);
default:
respond(['jsonrpc' => '2.0', 'id' => $request['id'] ?? null,
'error' => ['code' => -32601, 'message' => "unknown method {$method}"]]);
}Values travel as tagged objects, which is how scripts see native types across
the wire: a string is {"type": "string", "value": "..."}, a dict carries
entries, a list carries items. See the
protocol reference for every type and method.
Serving a Class
The handshake’s schema also lists classes, and the host turns each entry into
a proxy class. Construction answers object.new with the instance’s remote
reference; every listed method arrives as object.call_method carrying the
object_id from construction. The repository example serves a Greeter
whose state travels inside the object id itself, because the PHP built-in
server forgets everything between requests:
<?php
// Handshake excerpt: declare the class and its methods. The host synthesizes
// the constructor; listed methods become callable on instances.
'schema' => [
'functions' => [['name' => 'greet']],
'classes' => [
['name' => 'Greeter', 'methods' => [
['name' => 'greet'], ['name' => 'shout'], ['name' => 'rename'],
]],
],
'constants' => [],
],
// object.new: build the instance and answer with the bare remote reference.
function new_object(string $class, array $args): array
{
$state = match ($class) {
'Greeter' => ['name' => $args[0]['value'] ?? 'world'],
default => throw new RpcError("unknown class {$class}", -32602),
};
return [
'library' => 'phpdemo',
'class' => $class,
'id' => base64_encode(json_encode($state)),
];
}
// object.call_method: decode the state the id carries and dispatch.
function call_method(string $objectId, string $method, array $args): array
{
$state = json_decode(base64_decode($objectId), true) ?? [];
$name = $state['name'] ?? 'world';
switch ($method) {
case 'greet':
return ['type' => 'string', 'value' => "Hello, {$name}"];
case 'shout':
return ['type' => 'string', 'value' => strtoupper("Hello, {$name}!")];
case 'rename':
// Statelessness: mutation returns a fresh instance; the script rebinds.
return ['type' => 'remote', 'remote' => [
'library' => 'phpdemo', 'class' => 'Greeter',
'id' => base64_encode(json_encode(['name' => $args[0]['value'] ?? $name])),
]];
default:
throw new RpcError("unknown method {$method} on Greeter", -32602);
}
}Scripts use it like any class:
scriptling --plugin http://127.0.0.1:8080 -c '
import plugin.phpdemo as d
g = d.Greeter("Ada")
print(g.greet()) # Hello, Ada
print(g.shout()) # HELLO, ADA!
g = g.rename("Bob") # returns a new instance; rebind
print(g.greet()) # Hello, Bob
'A method may return a new instance (a remote value), and the script-side
proxy materializes it as a live object — that is how rename works. A server
with real storage (a database, Redis) keeps instances there and puts its key
in the id instead; the protocol takes either shape. object.destroy arrives
when an instance is released; a stateless server can answer null and move
on.
Running and Loading
PHP’s built-in server is enough:
php -S 127.0.0.1:8080 index.phpLoad it by URL and call it like any plugin:
scriptling --plugin http://127.0.0.1:8080 \
-c 'import plugin.hello; print(plugin.hello.greet("Ada"))'The handshake declares the short name hello; Scriptling imports it as
plugin.hello, exactly like an executable plugin. In production, terminate
TLS in front of the server and load the https:// URL; when the certificate
is self-signed (development, internal networks), name the URL with
--plugin-insecure to skip verification for it alone.
Authentication
The host authenticates with headers, and both forms are one line on the
script side: a bearer token with --plugin-header, or username and password
in the URL as Basic auth.
# the token stays out of the process listing
export SCRIPTLING_PLUGIN_HEADER="Authorization=Bearer $PLUGIN_TOKEN"
scriptling --plugin https://plugins.internal:8443 script.py
# username and password ride the URL as Basic auth
scriptling --plugin https://user:[email protected]:8443 script.pyOn the server side the header arrives as $_SERVER['HTTP_AUTHORIZATION']
(plain Bearer <token> or Basic <base64>). The snippet above enforces one
when PHPDEMO_TOKEN is set, exactly like the repository example: start it
with PHPDEMO_TOKEN=seekrit php -S 127.0.0.1:8080 index.php and a load
without the header fails with 401: missing or invalid bearer token, while
the --plugin-header "Authorization=Bearer seekrit" form above loads it.
HTTP Transport Notes
- The server owns its environment. The host connects to an HTTP plugin,
it does not spawn it, so
--plugin-env(which passes variables to executable plugins) does not apply: configure the PHP process’s environment where you start it. - Request/response only. The HTTP transport carries handshakes, function
calls, object lifecycle and batches, but the server cannot call back into
the host. This is not negotiated at load: a plugin that registers
callback-bearing functions loads without warning, and the failure happens
at call time. Load-time refusal would be the wrong default anyway, since
the host often cannot reach back to the network a plugin server sits on.
Host callbacks and
plugin.Logger(ctx)require the stdio transport. - Errors are JSON-RPC errors. An
errorobject in a response surfaces in the script as an ordinary error, so unknown functions or a failing handler report themselves plainly.
The same protocol served from Go is in examples/plugins/http-go, and the
bash example implements the stdio transport if you want to see both sides.