Skip to main content

Documentation

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:

NamespaceContents
Egav\Sdk\AuthTokenSource, StaticTokenSource, OIDCConfig, OIDCHeadlessTokenManager — the 3-step headless PKCE login, cached until just before expiry.
Egav\Sdk\ClientClient, ClientConfig — authenticated JSON requests to a named service resolved through a serviceUrls map, with exponential-backoff retry on transient/5xx (never 4xx).
Egav\Sdk\EnvelopeBaseResponse, ErrorResponse, Meta, ApiException, Decoder — the typed wire envelope; a >= 400 response throws a typed ApiException.
Egav\Sdk\GatesGates, 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 namespace Egav\Sdk\ is stable and correct, but a bare composer require egav/sdk from 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.

composer.jsonjson
{
  "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.

consume.phpphp
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 argumentMeaningDefault
serviceUrlsMap of app code → base URL. Required; must be non-empty.—
tokenSourceSupplies the bearer for authed calls. null for public-only usage.null
defaultHeadersHeaders sent on every request (e.g. X-App-Code, X-App-Instance-Guid).[]
insecureSkipVerifyDisable TLS verification for local self-signed certs. Never in prod.false
timeoutSecondsPer-request timeout.30
maxRetriesRetries on transient failures (connection error / 5xx). 4xx are never retried.2
backoffMsInitial 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:

TypeFields
BaseResponsesuccess: bool, status: string, message: string, data: mixed, meta: ?Meta
ErrorResponseerrorCode: int, message: string, success: bool, status: string
Metatotal, page, perPage, totalPages (all int) — the pagination block on list responses
ApiExceptionstatusCode: int (HTTP), errorCode: int (platform error_code); is a \RuntimeException
PHP has no generics, so BaseResponse::$data is mixed — 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 argumentMeaningDefault
authNBaseUrlAuthN base URL. Required.—
clientId / redirectUriA registered public OIDC client and its redirect URI. Required.—
email / passwordThe end-user credentials. Required; the password is never echoed in errors.—
scopeOIDC scope. Add offline_access for a refresh token.openid profile email
httpClientInjectable Guzzle client (tests, or verify=false for local certs).null
expirySkewSecondsSeconds before expires_in to proactively re-login.30
insecureSkipVerifyDisable 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 CP portal/me, resolving Identity{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 CP effective-features fetch 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 not blocked).
  • 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:

GateExceptionHTTPMeaning
1 authUnauthenticated401Bearer missing, malformed, or rejected
2 permissionPermissionDenied403AuthZ returned allowed: false
3 subscriptionSubscriptionInactive402Subscription lapsed / CP gate blocked; carries reason + action
4 featureFeatureDisabled403The plan does not enable the feature
5 quotaQuotaExceeded429current + cost exceeds the limit; carries current/limit/cost
anyUnavailable503CP/AuthZ unreachable, 5xx, or unusable payload — fail closed
gates.phpphp
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.