Search by

Thin PHP client for Synthigy's /data endpoint: reads, writes, XSQL, OAuth and typed code generation. No watch/SSE layer.

v0.2.4 2026-11-03 12:24 UTC

This package is auto-updated.

Last update: 2026-11-03 12:29:10 UTC


README

Thin, low-dependency (ext-curl + ext-json only, both bundled with virtually every PHP install) client for Synthigy's /data endpoint.

Scope: CRUD + auth + codegen. There is no watch/SSE/subscriptions layer — PHP's dominant runtime (PHP-FPM, one process per request) has no place to hold a long-lived streaming connection, which is the whole premise of the watch layer the other SDKs ship. A future watch port would need a long-lived worker process (Swoole, RoadRunner, ReactPHP or a daemonized CLI) to hold the SSE connection and somewhere to fan deltas out to request handlers — neither is assumed here. Everything else — reads, writes, XSQL, auth, typed codegen — is present.

Install

composer require synthigy/sdk

Requires PHP ≥ 8.1 with ext-curl and ext-json. Composer also links the synthigy-codegen CLI into vendor/bin/.

Hello world

use Synthigy\Synthigy;

Synthigy::connect();          // endpoint + identity from `synthigy exec`

// XSQL: the shape you write is the shape you get back
$movies = Synthigy::query(<<<'XSQL'
movie (release_year > ?since:int, limit 10)
  xid
  title
  ->genres
    name
XSQL, ['since' => 1990], actingAs: $userXid);

Run it with synthigy exec -- php app.php.

Import every SDK class you use (use Synthigy\SynthigyError;) or write it with a leading backslash (\Synthigy\SynthigyError). With use Synthigy\Synthigy; in scope, a partly qualified Synthigy\SynthigyError resolves to Synthigy\Synthigy\SynthigyError: new fails loudly, but catch and instanceof silently never match. PHPStan reports both.

One process, one client

Synthigy::connect() installs a process-wide default; every static verb (Synthigy::query(), Synthigy::sync(), ...) delegates to it. Identity is multiplexed per-call via actingAs:, never a second connect(). Constructing new \Synthigy\Client(...) directly is the escape hatch for tests or a genuine multi-endpoint script.

Synthigy::whoami(actingAs: $xid) answers who a call runs as: the user's xid, name, active flag, roles (group-conferred included), whether they are superuser, and whether this client may act as users at all. Use it when a user sees more or less than expected.

Reads

$movies = Synthigy::query(<<<'XSQL'
movie (release_year > ?y:int, limit 10)
  title
  ->genres
    name
XSQL, ['y' => 1990]);

$movie = Synthigy::query(<<<'XSQL'
movie (xid = ?xid:string)
  title
XSQL, ['xid' => $xid], op: 'get');         // one record, or null

$rows = Synthigy::sqlTemplate(
    'SELECT COUNT(*) AS n FROM {movie} WHERE {movie.release_year} > ?', [1990]);
  • ?name:type=default are named params, passed as an array.
  • ->genres is a left pull — a movie with no genres is still returned; -genres is inner and keeps only movies that have one; !genres keeps only movies with none (filters allowed, no fields under it).
  • Empty relations are omitted, never [] — use $movie['genres'] ?? [].
  • Counting and aggregation compute in the database: _count / _agg in XSQL, or sqlTemplate.

Writes

Synthigy::sync('movie', ['xid' => $xid, 'title' => 'Dune']);               // upsert, REPLACES link-sets
Synthigy::stack('user_rating', ['value' => 5, 'movie' => ['xid' => $xid]]); // additive
Synthigy::delete('movie', ['xid' => $xid]);                               // soft delete

slice (unlink without deleting) and purge (hard delete by filter) exist too.

Several operations in one round trip:

use function Synthigy\opQuery;
use function Synthigy\opStack;

$results = Synthigy::exec([
    opQuery("movie (limit 3)\n  title"),
    opStack('user_rating', ['value' => 4, 'movie' => ['xid' => $xid]]),
], actingAs: $userXid);

Files

$client->files() (or Synthigy::files()) speaks the engine's /files endpoints: the bytes, their versions and folders, under the same access control as your data. Each file is also a File record on /data. A file is a path ('/site/img/logo.png'), an xid or the file; a folder is a path ending in / ('/' is ROOT), an xid or the folder.

$files = Synthigy::files();
$files->folder('/site/img/');                           // made with every missing folder above it
$fi = $files->put('/site/img/logo.png', fopen('logo.png', 'rb'), 'image/png'); // creates, or replaces
// $fi['xid'], $fi['size'], $fi['hash'] (SHA-256, the download ETag), $fi['uploaded_by']['name']

$bytes = $files->download('/site/img/logo.png');        // [0, 1023] as $range for part of it
$files->put($fi, $newBytes, ifMatch: $fi);              // FILE_CHANGED if someone was first
$files->put(['name' => 'scan.pdf'], $bytes);            // a new file in no folder, every time
$files->list('/site/');                                 // ['files' => ..., 'folders' => ...]; null: no folder
$files->search('site logo');                            // paths holding the words in order, shortest first
$files->move('/site/img/logo.png', '/brand/');          // keeps the name; never overwrites
$files->move('/site/img/', '/assets/img/');             // a folder
$vs = $files->versions($fi);                            // the last ten, newest first
$files->restore($fi, $vs[1]);
$files->share('/site/', 'read', [$userXid]);
$files->delete('/site/img/logo.png');                   // or an empty folder: '/site/img/'

put never creates folders (a missing one is FOLDER_NOT_FOUND); folder does. put takes a string of bytes or a readable stream. download(ifNoneMatch: $hash) answers null while the file is unchanged; info answers null for a file you cannot see.

A browser cannot send your token (<img src>, a link in an email). Files in a public folder load from a plain link, built without a request; serve answers a browser's request from your server with your token, Range and 304 included:

$files->folder('/site/', ['public' => true, 'cache' => 86400]);
$src = $files->url('/site/img/logo.png');               // or an xid; url($ref, true) opens it in the browser
$out = $files->serve('/private/logo.png', getallheaders()); // HttpResponse: status, headers, body
http_response_code($out->status);
foreach ($out->headers as $k => $v) header("$k: $v");
echo $out->body;

A folder's own public and cache win, else the nearest folder above that has one (null clears its own); the folder shows what applies as public and cache, what is set on it under settings. File User may set the cache but not public (ATTRIBUTE_FORBIDDEN).

One put carries up to the server's SYNTHIGY_SERVER_MAX_BODY (25 MB by default). The engine's files guide covers storage, access and retention; error codes include FILE_NOT_FOUND, FOLDER_NOT_FOUND, FILE_CHANGED, FILE_TOO_LARGE, VERSION_EXPIRED, INVALID_PATH, AUDIT_UNAVAILABLE and FILES_UNAVAILABLE (no files module on the server).

Errors

Everything throws \Synthigy\SynthigyError (extends RuntimeException) with stable ->code, derived ->category (auth | iam | validation | not_found | conflict | rate_limit | network | internal) and ->retryable. Structured fields when the server sends them: ->hint, ->entity, ->path, ->errorLine/->col (an XSQL source position — named errorLine rather than line because \Exception itself already declares a non-nullable $line, the throw site, and PHP won't let a child class redeclare it with an incompatible type), ->diagnostics, ->requestId (the X-Request-Id the server answered with — correlate with server logs). Discriminate on ->code, never the message.

->retryable is true for network, rate_limit and internal codes, and for any error the server marks retryable. A code this SDK doesn't know yet is category internal but not retryable.

Auth

  • Client credentials (clientId+clientSecret): tokens minted from /oauth/token, cached per audience, refreshed 30s before expiry, one automatic clear-and-retry on 401. The cache outlives the request — see Reusing tokens across requests. A refused mint is INVALID_CLIENT (wrong id or secret, inactive client), INVALID_SCOPE, or INVALID_AUDIENCE (the client isn't linked to the API), else UNAUTHORIZED; the message carries the server's error_description.
  • Static token: '...' for scripts/tests (token: '' for authless dev).
  • With none of the above, resolution continues: under SYNTHIGY_SUPERVISED=1 the SDK asks its supervising parent (synthigy exec/agent, or a robotics commander) for a token over the process's own stdio — CLI-only, meaningless under PHP-FPM (no stdio to a parent there) — then falls back to the SYNTHIGY_TOKEN env var, then throws SynthigyError(code: 'NO_TOKEN') with a message that teaches the fix.
  • actingAs is server-verified impersonation for trusted confidential clients (the BFF model) — the SDK never handles end-user OAuth redirects.
  • audience (or $SYNTHIGY_AUDIENCE): leave it unset. Every mint names Client::PLATFORM_AUDIENCE (https://synthigy.com), the audience /data and the other platform endpoints require; a token naming no audience would get an identity-only one that /data rejects. A client not linked to the platform API fails the mint with INVALID_AUDIENCE. Client::token($audience) mints for a different API, per call (e.g. a service that trusts Synthigy as its IdP).

Reusing tokens across requests

PHP forgets everything between requests, so a client that kept its token only in memory would mint a new one on every page view. Client-credentials tokens therefore go to a TokenStore, and every request reuses the token until 30s before it expires. The default is a FileTokenStore: one 0600 file per token in a 0700 directory under the system temp dir, one directory per OS user. Nothing to configure.

  • File names are an HMAC of the endpoint, client, scope and audience keyed by the client secret, so a token is only reused by the credentials that minted it, and a rotated secret mints a new one.
  • A 401 deletes the stored token, so every worker mints a fresh one.
  • The store is a cache, never a failure: if the directory can't be created, is a symlink, or belongs to another user — or a custom store throws — the SDK mints per request instead.
  • tokenStore: new FileTokenStore('/var/cache/my-app') picks the directory; tokenStore: false keeps tokens in the process only.

Any shared cache works. APCu, shared by every PHP-FPM worker on the host:

use Synthigy\Synthigy;
use Synthigy\TokenStore;

final class ApcuTokenStore implements TokenStore
{
    public function get(string $key): ?array
    {
        $v = apcu_fetch('synthigy:' . $key, $ok);
        return $ok && is_array($v) ? $v : null;
    }
    public function put(string $key, string $token, float $expiresAt): void
    {
        apcu_store('synthigy:' . $key, ['token' => $token, 'expires_at' => $expiresAt],
            max(1, (int)($expiresAt - time())));
    }
    public function delete(string $key): void { apcu_delete('synthigy:' . $key); }
}

Synthigy::connect(clientId: 'my-app', clientSecret: getenv('APP_SECRET'),
    tokenStore: new ApcuTokenStore());

Logging users in (loginStart / loginComplete)

Authorization code + PKCE for a confidential server: the SDK owns the protocol, your app owns sessions, cookies and routing. loginStart() returns a URL, loginComplete() takes the callback's code/state. The in-flight login lives in a LoginStore you pass as loginStore: — put($state, $login) and one-shot take($state).

Under PHP-FPM every request is a new process, so keep logins in the session: SessionLoginStore does, and drops logins older than its TTL (ttlSeconds:, default 600). MemoryLoginStore is for one long-lived process only (tests, RoadRunner, Swoole); under FPM every login would fail LOGIN_STATE_UNKNOWN.

use Synthigy\SessionLoginStore;
use Synthigy\Synthigy;

session_start();
Synthigy::connect(clientId: 'my-app', clientSecret: getenv('APP_SECRET'),
    loginStore: new SessionLoginStore());

// /login
header('Location: ' . Synthigy::loginStart(CALLBACK, returnTo: $_GET['returnTo'] ?? '/')['url']);

// /auth/callback
if (isset($_GET['error'])) {                       // the user cancelled at the IdP
    header('Location: ' . (Synthigy::loginCancel($_GET['state'] ?? '')['return_to'] ?? '/'));
    exit;
}
$done = Synthigy::loginComplete($_GET['code'], $_GET['state'], CALLBACK);
$_SESSION['user'] = $done['user'];                 // ['xid', 'name', 'scopes']
header('Location: ' . $done['return_to']);
  • $done['user']['xid'] is read from the id_token — no /data lookup. Pass it as actingAs: to act on the user's behalf.
  • No store → NO_LOGIN_STORE; no clientSecret → LOGIN_REQUIRES_CONFIDENTIAL_CLIENT; SessionLoginStore without session_start() → SESSION_NOT_STARTED. Other codes: LOGIN_STATE_UNKNOWN, LOGIN_NONCE_MISMATCH, LOGIN_EXCHANGE_FAILED (with ->status).
  • Don't make the session cookie SameSite=Strict: the IdP's redirect back is a cross-site navigation, a Strict cookie isn't sent on it, and the callback fails LOGIN_STATE_UNKNOWN.
  • publicEndpoint: when the browser reaches the IdP on a different URL than PHP does (containers, reverse proxies).
  • The code exchange never retries: an authorization code is one-shot.

Code generation

Write your queries in .xsql files and get typed functions for them. The server compiles the queries, so the types always match what it returns.

1. Get a server. In your project folder:

synthigy env init
synthigy up

The first up prints a /setup link; open it and pick a database. (No browser? synthigy up --bundle sqlite skips the wizard.) Already have a server? Skip this step.

2. Deploy your data model in the modeler (or from code with Synthigy::deploy(), as a client with the Dataset Developer role).

3. Connect as your app. Create its client once, then save it to the project:

synthigy iam add-client "My App" --id my-app --type confidential \
  --role "Dataset Explorer" --api Synthigy --grant client_credentials --local
synthigy connect http://localhost:7887 --client-id my-app

add-client prints the secret once; connect asks for it. Code is generated for what this app is allowed to see. (--local works on the server's own machine; for a remote server, create the client in the console.)

4. Install the SDK:

composer require synthigy/sdk

5. Write a query in xsql/movies.xsql:

@search list
movie (release_year > ?since:int=1990, limit ?limit:int=20)
  xid
  title
  release_year

6. Generate:

synthigy exec -- vendor/bin/synthigy-codegen gen xsql/ --out gen/ops.php

This writes gen/ops.php (namespace Generated, change it with --namespace), and saves xsql/schema.json and xsql/ops.ir.json next to your queries. After a model change, add --pull to refresh both (or synthigy schema pull, which writes the same xsql/schema.json).

7. Use it:

<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/gen/ops.php';

use Synthigy\Synthigy;

Synthigy::connect();
$movies = \Generated\Movie::list(['since' => 2000]);
echo json_encode(array_column($movies, 'title')), "\n";
synthigy exec -- php app.php

synthigy exec gives your program the server address and the app's identity. Without it, pass them yourself: Synthigy::connect($endpoint, clientId: ..., clientSecret: ...).

After you edit a query, run step 6 again. As long as the .xsql files are unchanged it works offline from xsql/ops.ir.json; after an edit it needs the server, and it never generates from outdated results. In CI:

vendor/bin/synthigy-codegen check xsql/

What to commit: your .xsql files and xsql/ops.ir.json. xsql/schema.json is your whole data model, so commit it only in a private repo.

@batch ops become one-round-trip methods on Batches, and every entity gets typed Writes::sync<Entity> / stack<Entity> / delete<Entity>. Every generated file passes php -l before it is written. A @batch method forwards one $params array to every member query.

Development workflow

composer install
composer analyse       # PHPStan level 8 over src/, tests/, bin/
composer test          # analyse + the hermetic unit suite (no network)
composer test:live     # analyse + the live suite (needs creds, see below)

composer test runs PHPStan first and fails the whole command if analysis fails. That's deliberate: the typed tier here is PHPDoc, so static analysis is the only thing that keeps the annotations honest — putting the gate inside the test command means anything that runs the tests runs the gate, with no separate CI step to forget.

No PHP installed? Use Docker

Everything above works without a local PHP toolchain:

docker run --rm -v "$PWD":/app -w /app php:8.3-cli sh -c '
  apt-get update -qq && apt-get install -y -qq unzip
  curl -sSL https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
  composer install --no-interaction
  composer test
'

Add --network host when the container needs to reach a Synthigy server on localhost, plus -e SYNTHIGY_TEST_* for the live suite.

Live suite

composer test:live runs tests/IntegrationTest.php against a real server. It's excluded from the default run (@group live) and skips cleanly when credentials are absent, so it never fails a hermetic run.

SYNTHIGY_TEST_ENDPOINT=http://localhost:7887 \
SYNTHIGY_TEST_CLIENT_ID=... SYNTHIGY_TEST_CLIENT_SECRET=... \
SYNTHIGY_TEST_AUDIENCE=https://synthigy.com \
  composer test:live
# + SYNTHIGY_TEST_LOGIN_CLIENT_ID/_SECRET/_USER/_PASSWORD for the headless browser-login case

SYNTHIGY_TEST_TOKEN=<bearer> works instead of the id/secret pair. SYNTHIGY_TEST_ENTITY picks the entity to exercise (default movie). It covers token minting, schema, search/get, sqlTemplate, XSQL, batched exec, operator filtering, typed errors, and one write→read→delete round trip that creates and removes its own record (client-minted xid, cleaned up in a finally).

tests/FilesLiveTest.php (same live group) exercises /files end to end: uploads, ranges, versions, folders, sharing and the refusals between a File Admin, a File User and a File Reader. It skips unless SYNTHIGY_FILES_TEST_ENDPOINT is set, plus SYNTHIGY_FILES_TEST_{ADMIN,USER,READER}_{ID,SECRET} (client-credentials clients whose service users hold those roles). Every name carries a run id and the test removes what it created.

License

MIT — see LICENSE. The SDKs are permissive client libraries; the Synthigy engine is source-available under the Business Source License 1.1.