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
None
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.
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.
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.- 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);
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 (matches the X-Request-Id the SDK sends — correlate with
server logs). Discriminate on ->code, never the message.
Auth
- Client credentials (
clientId+clientSecret): tokens minted from/oauth/token, cached per audience, refreshed 30s before expiry, one automatic clear-and-retry on 401. - 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: some servers require an explicitclient_credentialsaudience for/dataaccess — the platform's audience model is opt-in by design (a token minted with no audience resolves to an identity-only one, regardless of the client's roles or API links; there is no server-side default to configure around this). Passaudience: 'https://synthigy.com'(or whatever your server's/dataaudience is) once atconnect()/Clientconstruction and every call this Client makes uses it automatically — no need to thread it through everysearch()/sync()/etc. call.Client::token($audience)still accepts a per-call override for minting tokens for a different audience (e.g. a third-party service Synthigy federates for).
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. MemoryLoginStore is for one long-lived process only (tests,
RoadRunner, Swoole); under FPM every login would fail LOGIN_STATE_UNKNOWN.
final class SessionLoginStore implements Synthigy\LoginStore { public function put(string $state, array $login): void { $_SESSION['synthigy_login'][$state] = $login; } public function take(string $state): ?array { $login = $_SESSION['synthigy_login'][$state] ?? null; unset($_SESSION['synthigy_login'][$state]); return $login; } } 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. Other codes:LOGIN_STATE_UNKNOWN,LOGIN_NONCE_MISMATCH,LOGIN_EXCHANGE_FAILED(with->status). 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 --db 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.
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).
License
MIT — see LICENSE. The SDKs are permissive client libraries; the Synthigy engine is fair-code under the Sustainable Use License.