PHP client SDK
Consume the platform from PHP — headless OIDC auth, a retrying JSON client that resolves named services, and the five access gates.
The platform, consumed from PHP
egav/sdk is the SynaptaGrid platform client for PHP — an existing program calls the platform over HTTP without becoming a platform-native app. It is one of the four client SDKs (Go, Rust, PHP, Node), all the same shape: a headless OIDC token manager, an authenticated retrying JSON client that decodes the standard BaseResponse envelope, and the five access gates ported natively. It is a consumer SDK (Tier 1–2) and deliberately omits the app-building runtime — no app factory, tenant middleware, outbox, or scoped CRUD. That runtime lives in the two app SDKs; this package is for programs that consume the platform from PHP.
Four namespaces carry the whole surface:
| Namespace | Contents |
|---|---|
Egav\Sdk\Auth | TokenSource, StaticTokenSource, OIDCConfig, OIDCHeadlessTokenManager — the 3-step headless PKCE login, cached until just before expiry. |
Egav\Sdk\Client | Client, ClientConfig — authenticated JSON requests to a named service resolved through a serviceUrls map, with exponential-backoff retry on transient/5xx (never 4xx). |
Egav\Sdk\Envelope | BaseResponse, ErrorResponse, Meta, ApiException, Decoder — the typed wire envelope; a >= 400 response throws a typed ApiException. |
Egav\Sdk\Gates | Gates, GatesConfig, Identity, Entitlements, and the gate exceptions — the five access gates with the platform's status contract. |
Install and distribution status
The package is egav/sdk, PHP 8.3+, PSR-4 namespace Egav\Sdk\, version 0.1.0. Its only runtime dependency is guzzlehttp/guzzle ^7.8.
Honest status: the package is not on Packagist yet. Publish is pending; the import namespaceEgav\Sdk\is stable and correct, but a barecomposer require egav/sdkfrom the public index does not resolve it today.
Until it is tagged and published, depend on it through a Composer path repository pointing at a local checkout of the SDK. When it ships to Packagist this block collapses to an ordinary require line — the use statements in your code do not change.
{
"repositories": [{ "type": "path", "url": "../egav-sdk-php" }],
"require": { "egav/sdk": "*" }
}Everything runs in Docker if you have no host PHP or Composer toolchain — composer:2 install then php:8.5-cli vendor/bin/phpunit against a bind-mounted checkout.
A worked example
Construct a token source, hand it to a Client alongside the service URL map, and call a named service by app code plus path. get() and post() return an Egav\Sdk\Envelope\BaseResponse; any >= 400 status throws Egav\Sdk\Envelope\ApiException carrying both the HTTP status and the platform error_code.
use Egav\Sdk\Auth\OIDCConfig;
use Egav\Sdk\Auth\OIDCHeadlessTokenManager;
use Egav\Sdk\Client\Client;
use Egav\Sdk\Client\ClientConfig;
use Egav\Sdk\Envelope\ApiException;
$auth = new OIDCHeadlessTokenManager(new OIDCConfig(
authNBaseUrl: $authnUrl, // https://authn-api.your-domain.example:5209
clientId: $clientId, // a registered public OIDC client
redirectUri: $redirectUri, // https://your-domain.example:3200/callback
email: $email,
password: $password,
// insecureSkipVerify: true, // local self-signed certs only, never in prod
));
$client = new Client(new ClientConfig(
serviceUrls: [
'control-plane' => $cpUrl, // https://cp-api.your-domain.example:5207
'authz' => $authzUrl, // https://authz-api.your-domain.example:5206
],
tokenSource: $auth,
defaultHeaders: ['X-App-Code' => 'demo'],
));
try {
$resp = $client->get('control-plane', '/v1/control-plane/portal/me');
// $resp is Egav\Sdk\Envelope\BaseResponse — full schema: /users-orgs
echo $resp->data['email'] ?? '';
} catch (ApiException $e) {
// $e->statusCode (HTTP) and $e->errorCode (platform error_code) are both set
error_log(sprintf('me failed: http %d code %d', $e->statusCode, $e->errorCode));
}The full response schema of /portal/me — and every other way to read the current user, their organization, and other members — is documented in Users & organizations.
The authenticated client
ClientConfig is an immutable, named-argument value object. serviceUrls is the client-side equivalent of the Control Plane's resolve_service — one map, one source of truth, so no service URL is hardcoded at a call site. You address a service by its app code ('control-plane', 'authz'); an unknown app code throws before any request is made.
ClientConfig argument | Meaning | Default |
|---|---|---|
serviceUrls | Map of app code → base URL. Required; must be non-empty. | — |
tokenSource | Supplies the bearer for authed calls. null for public-only usage. | null |
defaultHeaders | Headers sent on every request (e.g. X-App-Code, X-App-Instance-Guid). | [] |
insecureSkipVerify | Disable TLS verification for local self-signed certs. Never in prod. | false |
timeoutSeconds | Per-request timeout. | 30 |
maxRetries | Retries on transient failures (connection error / 5xx). 4xx are never retried. | 2 |
backoffMs | Initial backoff between retries, doubling each attempt. | 200 |
The Client applies the default headers, adds Authorization: Bearer <token> when a tokenSource is set, and retries transient failures — a connection error or a >= 500 response — with exponential backoff, never a 4xx. Three request methods are exposed:
get(string $app, string $path): BaseResponse— GET, decoded through the envelope.post(string $app, string $path, mixed $body = null): BaseResponse— POST with a JSON body, decoded.do(string $app, string $method, string $path, mixed $body = null): ResponseInterface— the raw PSR-7 response for callers that want to decode themselves.
Decoder::decode() turns a PSR-7 response into a typed BaseResponse on success and throws ApiException on >= 400, reading error_code and message out of the standard ErrorResponse shape (falling back to the HTTP status text when the body carries no message). The envelope types mirror egav_base exactly:
| Type | Fields |
|---|---|
BaseResponse | success: bool, status: string, message: string, data: mixed, meta: ?Meta |
ErrorResponse | errorCode: int, message: string, success: bool, status: string |
Meta | total, page, perPage, totalPages (all int) — the pagination block on list responses |
ApiException | statusCode: int (HTTP), errorCode: int (platform error_code); is a \RuntimeException |
PHP has no generics, soBaseResponse::$dataismixed— the decoded JSON payload, an array for object bodies.
The headless OIDC token manager
The platform has no plain ROPC endpoint — portal login is AuthN's 3-step OIDC headless flow with PKCE. OIDCHeadlessTokenManager implements TokenSource and runs it end to end: POST authorize/headless starts a session, POST headless/credentials submits the credentials for an authorization code, and POST token exchanges that code plus the PKCE verifier for an access token. It caches the token and returns it from token() until expirySkewSeconds (default 30) before expires_in, then re-runs the login automatically.
OIDCConfig argument | Meaning | Default |
|---|---|---|
authNBaseUrl | AuthN base URL. Required. | — |
clientId / redirectUri | A registered public OIDC client and its redirect URI. Required. | — |
email / password | The end-user credentials. Required; the password is never echoed in errors. | — |
scope | OIDC scope. Add offline_access for a refresh token. | openid profile email |
httpClient | Injectable Guzzle client (tests, or verify=false for local certs). | null |
expirySkewSeconds | Seconds before expires_in to proactively re-login. | 30 |
insecureSkipVerify | Disable TLS verification for local self-signed certs. | false |
Two behaviours are worth stating plainly. Accounts with MFA enabled are rejected — the headless flow is credential-only and raises a clear ApiException rather than half-completing. And with the default scope there is no refresh token, so the manager simply re-runs the full login when the cached token expires; add offline_access if you want a refresh token instead. When you already hold a token by other means, StaticTokenSource is the trivial TokenSource that returns it unchanged. For the platform's auth model overall, see Auth & the five gates.
The five gates in PHP
Gates applies the same five access checks a Python backend app uses to protect an endpoint, in the same order and with the same fail-closed contract. GatesConfig carries the two service base URLs the gates call (Control Plane and AuthZ) plus the app's service x-api-key(s); authzApiKey falls back to cpApiKey for single-key dev setups, though per-service keys are the rule.
authenticate(string $userBearer): Identity— gate 1. Validate the inbound end-user bearer against CPportal/me, resolvingIdentity{userGuid, orgGuid}.requirePermission(Identity $id, string $permissionCode, string $resourceType = 'app'): void— gate 2. AuthZ confirms the user may perform the action (the substring after the last.in$permissionCode).entitlements(string $orgGuid): Entitlements— one CPeffective-featuresfetch feeding gates 3–5. The three checks below are pure functions over the result.requireActiveSubscription(Entitlements $ent): void— gate 3. The org's subscription is active (or the CP gate object is notblocked).requireFeature(Entitlements $ent, string $code): void— gate 4. The org's plan enables the feature.enforceQuota(Entitlements $ent, string $code, int $current, int $cost = 1): int— gate 5. The org is under its plan limit; returns the resolved limit.
Two credentials, correct trust model — no shortcut. The end-user bearer is used only for gate 1 (portal/me); the app's service x-api-key authenticates gates 2–5, which hit /internal/* service-to-service endpoints. The two are never crossed — the user bearer never reaches /internal/*, and the app key never reaches portal/*. Every gate fails closed: an unreachable or 5xx Control Plane or AuthZ raises Unavailable (503) rather than default-allowing.
Each failed gate throws a GateException subclass carrying a stable HTTP status, errorCode, and a machine-readable kind. Catch GateException to map any gate failure to a response, or a specific subclass for per-gate handling:
| Gate | Exception | HTTP | Meaning |
|---|---|---|---|
| 1 auth | Unauthenticated | 401 | Bearer missing, malformed, or rejected |
| 2 permission | PermissionDenied | 403 | AuthZ returned allowed: false |
| 3 subscription | SubscriptionInactive | 402 | Subscription lapsed / CP gate blocked; carries reason + action |
| 4 feature | FeatureDisabled | 403 | The plan does not enable the feature |
| 5 quota | QuotaExceeded | 429 | current + cost exceeds the limit; carries current/limit/cost |
| any | Unavailable | 503 | CP/AuthZ unreachable, 5xx, or unusable payload — fail closed |
use Egav\Sdk\Gates\Gates;
use Egav\Sdk\Gates\GatesConfig;
use Egav\Sdk\Gates\GateException;
$gates = new Gates(new GatesConfig(
controlPlaneUrl: $cpUrl,
authzUrl: $authzUrl,
cpApiKey: $cpApiKey, // this app's inbound CP x-api-key
authzApiKey: $authzApiKey, // this app's inbound AuthZ x-api-key
));
try {
$id = $gates->authenticate($userBearer); // 1 -> 401 / 503
$gates->requirePermission($id, 'project.create'); // 2 -> 403 / 503
$ent = $gates->entitlements($id->orgGuid); // one CP fetch for 3-5
$gates->requireActiveSubscription($ent); // 3 -> 402
$gates->requireFeature($ent, 'projects'); // 4 -> 403
$gates->enforceQuota($ent, 'max_projects', $currentCount); // 5 -> 429 (returns limit)
// ... all five gates passed: perform the action.
} catch (GateException $e) {
// $e->httpStatus / $e->kind are the stable response tags.
http_response_code($e->httpStatus);
}The method surface is identical across every client SDK — Go Authenticate / RequirePermission / …, PHP authenticate / requirePermission / … — and the failed-gate status contract is stable everywhere. Inside a Python backend app the same five checks compose from FastAPI dependencies (RequireAuthz plus the feature_gates); see SDKs & clients for the shared concept and the full package table, and Auth & the five gates for how the trust model is enforced platform-wide.