synthigy / sdk
Thin PHP client for Synthigy's /data endpoint: reads, writes, XSQL, OAuth and typed code generation. No watch/SSE layer.
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
Suggests
- ext-session: Needed by SessionLoginStore
Provides
None
Conflicts
None
Replaces
None
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=defaultare named params, passed as an array.->genresis a left pull — a movie with no genres is still returned;-genresis inner and keeps only movies that have one;!genreskeeps 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/_aggin XSQL, orsqlTemplate.
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 isINVALID_CLIENT(wrong id or secret, inactive client),INVALID_SCOPE, orINVALID_AUDIENCE(the client isn't linked to the API), elseUNAUTHORIZED; the message carries the server'serror_description. - Static
token: '...'for scripts/tests (token: ''for authless dev). - With none of the above, resolution continues: under
SYNTHIGY_SUPERVISED=1the 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 theSYNTHIGY_TOKENenv var, then throwsSynthigyError(code: 'NO_TOKEN')with a message that teaches the fix. actingAsis 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 namesClient::PLATFORM_AUDIENCE(https://synthigy.com), the audience/dataand the other platform endpoints require; a token naming no audience would get an identity-only one that/datarejects. A client not linked to the platform API fails the mint withINVALID_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: falsekeeps 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/datalookup. Pass it asactingAs:to act on the user's behalf.- No store →
NO_LOGIN_STORE; noclientSecret→LOGIN_REQUIRES_CONFIDENTIAL_CLIENT;SessionLoginStorewithoutsession_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 failsLOGIN_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.