westyx/nexus

Westyx Nexus PHP SDK - secrets, configs and feature flags

Maintainers

Package info

gitlab.com/westyx/nexus/sdk/php

pkg:composer/westyx/nexus

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v0.12.0 2026-08-03 12:50 UTC

This package is auto-updated.

Last update: 2026-08-03 11:15:26 UTC


README

PHP 8.3+ SDK for Westyx Nexus - centralized secrets, configuration, and feature flags.

Published on Packagist and on the Westyx GitLab Composer registry.

What's new in v0.12.0

  • Per-user targeting through OpenFeature - the provider now uses the EvaluationContext. A boolean evaluation carrying a targetingKey is resolved for that identity through the AB Testing add-on, and reports TARGETING_MATCH; one request covers every flag in the snapshot and the results are memoised, so a page resolving twenty flags for a user makes one call. See OpenFeature Provider.
  • The OpenFeature provider ships in this package - WestyxNexus\OpenFeature\NexusProvider, installable with composer require westyx/nexus plus composer require open-feature/sdk. It is the same arrangement as the Laravel service provider: a suggest, so a consumer who does not use it installs nothing extra, which a CI job asserts on every commit.
  • findFlag() - returns ?bool, so "the flag does not exist" is distinguishable from "it exists and is off". getFlag() is now expressed in terms of it and behaves exactly as before.
  • NexusAbAddonNotAvailableException - the 403 from evaluateAb() has its own type, matching the other Nexus SDKs, instead of a message on the generic exception.
  • Attribute values are strings, and are checked - evaluateAb() rejects a non-string attribute value naming the attribute and the type it received, before the request goes out. A null value is treated as unset.
  • Numbers resolve as numbers - resolveIntegerValue() and resolveFloatValue() report TYPE_MISMATCH instead of coercing. A whole-numbered float is still an integer, since JSON has one number type.

See the CHANGELOG for the full list, including the breaking changes.

What's new in v0.11.0

  • Bring your own HTTP client - the SDK is built on PSR-18 and PSR-17, so it uses whatever HTTP stack your project already has. Guzzle still works and is no longer required; installing westyx/nexus brings 6 packages and about 1 MB. See HTTP client.
  • Laravel service provider - composer require westyx/nexus in a Laravel app is all it takes: app('nexus') and constructor injection both work, and SDK diagnostics go to your application log automatically. See Laravel.
  • PSR-3 logging, wired through - sync results, stream activity and WIF session exchanges are reported through the logger you pass, with values in the PSR-3 $context array. Secret values and secret key names never appear in log output, and a permanent test enforces it.
  • File-type secrets live in a private directory - each client creates its own directory with a random name and mode 0700; the files inside are created 0600 at creation time. Two clients holding the same secret never share a file.
  • Stream deadlines - the live-update stream has a connection deadline and a liveness deadline that resets on every byte, so a connection dropped by a load balancer is detected instead of read as healthy.
  • The cache serves what it has - a 304 Not Modified and a brief network failure both keep serving the last snapshot the server sent, so reads return real values rather than their defaults.
  • One status-code contract - every endpoint reports the same condition the same way, with Retry-After and quarantine details preserved wherever they occur.

See the CHANGELOG for the full list, including the breaking changes.

What's new in v0.10.0

  • Version alignment - released at v0.10.0 in lockstep with the Westyx Nexus SDK suite.

What's new in v0.9.0

  • aws_iam WIF provider (AWS IAM Caller Identity) - authenticates non-EKS AWS compute (ECS/Fargate, Lambda, plain EC2) that has IAM credentials but no OIDC token. The SDK SigV4-signs an STS GetCallerIdentity request (never sent to AWS) and posts it to the token exchange; Nexus replays it against a pinned STS endpoint to prove your IAM role. The signed X-Nexus-Server-ID is your service's own base URL host - a captured request is valid for that one service only, and there is nothing to configure. Requires the optional aws/aws-sdk-php package. See AWS IAM.
  • Security hardening - NexusClient::create() rejects plain-http base URLs (loopback excepted); WIF reads now transparently refresh an expired session and fail closed (a WIF-only client never falls back to an empty X-Nexus-API-Key, throwing NexusSessionExpiredException instead); the GCP metadata read is bounded (64 KiB) and the token-exchange read is bounded (1 MiB); auto-detection stats the AWS/Azure token file instead of trusting a stale env var.
  • WIF test suite - per-provider token-source tests, auto-detect dispatch, the aws_iam signed-payload shape + SigV4 signed-header assertions, https enforcement, and clock-driven session expiry/refresh (fail-closed and API-key-fallback paths).

What's new in v0.8.2

  • WIF GCP fix - the gcp provider now fetches a real Google-signed OIDC identity token from the GCE metadata server. It previously read the GOOGLE_APPLICATION_CREDENTIALS key file, which is not a JWT and was rejected by the token exchange. Auto-detection was also aligned with the actual token sources (AWS_WEB_IDENTITY_TOKEN_FILE, a GCE metadata probe for gcp, and AZURE_FEDERATED_TOKEN_FILE). Verified end-to-end against a real GCE workload.

What's new in v0.8.1

  • Docs fix - corrected the example service base URL throughout the documentation and the NexusConfig doc-comment from the non-existent https://<slug>.api.westyx.dev to the correct https://<slug>.westyx.dev (the deployed host format, consistent with the other SDKs). No API or behavior change.

What's new in v0.8.0

  • OpenFeature provider - implements the OpenFeature PHP SDK Provider interface, wrapping an existing NexusClient. It ships inside this package as of v0.12.0; see the OpenFeature section.

What's new in v0.6.0

  • SSE reconnect after polling fallback - after the SSE stream falls back to TTL polling (due to max transport errors or rate-limiting), the SDK now schedules a reconnect attempt. sseReconnectCooldown controls the delay, clamped to [1 s, 300 s]. Previously the stream stayed in polling mode indefinitely until the client was restarted.

What's new in v0.5.1

  • Security hardening - response bodies no longer embedded in exception messages; file-type secret temp file paths hash both key name and value (SHA-256); secret not-found errors truncate the key name.
  • Thread-safe WIF session - session state uses atomic references; concurrent token refresh calls are serialized.
  • SSE event whitelist - only recognized event names trigger a sync and observer callbacks.
  • CI improvements - test stage runs on every MR; publish guard requires tag to be on main.

Installation

composer require westyx/nexus

The package is on Packagist, so nothing else is needed. To install it from the Westyx GitLab Composer registry instead, add that registry to your project's composer.json:

{
    "repositories": [
        {
            "type": "composer",
            "url": "https://gitlab.com/api/v4/projects/82742835/packages/composer/packages.json"
        }
    ]
}

You also need a PSR-18 HTTP client. Most projects already have one; if yours does not, any of these will do:

composer require guzzlehttp/guzzle          # or
composer require symfony/http-client nyholm/psr7

Quick Start

use WestyxNexus\NexusClient;
use WestyxNexus\NexusConfig;

$client = NexusClient::create(new NexusConfig(
    baseUrl: 'https://yourslug.westyx.dev',
    apiKey:  getenv('NEXUS_API_KEY'),
));

$host    = $client->getConfig('db_host');
$pass    = $client->getSecret('db_password');
$enabled = $client->getFlag('new-feature', false);

// SSE live updates (long-running CLI/daemon only):
$client->connectStream();

HTTP client

The SDK talks to Nexus through a PSR-18 ClientInterface and PSR-17 factories. Pass your own to reuse the stack your application already configures - its proxy settings, middleware and instrumentation apply to Nexus traffic too:

$client = NexusClient::create(
    $config,
    $myPsr18Client,        // Psr\Http\Client\ClientInterface
    $myRequestFactory,     // Psr\Http\Message\RequestFactoryInterface  (optional)
    $myStreamFactory,      // Psr\Http\Message\StreamFactoryInterface   (optional)
);

Pass nothing and php-http/discovery finds whichever implementation the project has installed. If there is none, create() raises an error naming what to install.

Status codes. PSR-18 clients return 4xx and 5xx responses rather than throwing, and the SDK maps every one of them to a typed exception itself. So the exceptions to catch around SDK calls are WestyxNexus\Exceptions\NexusException and its subclasses, not your HTTP client's - the SDK never lets an implementation-specific error type reach your code.

Live updates are the exception. PSR-18's sendRequest() returns a complete response, and whether the body is buffered first is left to the implementation - there is no standard way to ask for incremental delivery, so a buffering client would block forever on an endless event stream. connectStream() therefore uses ext-curl directly, which is also what gives it real connection and liveness deadlines. Without ext-curl it raises an actionable error; everything else, TTL polling included, works normally.

API Reference

Read

$client->getConfig(string $key, mixed $default = null): mixed
$client->getAllConfigs(): array
$client->getSecret(string $key): ?string          // text-type secrets
$client->getSecretFilePath(string $key): ?string   // file-type secrets (temp path)
$client->getFlag(string $key, bool $default = false): bool
$client->getAllFlags(): array

Write (secret key required)

These write methods require a secret key (backend services). A public key (browser/frontend, safe to expose) is read-only and raises NexusPublicKeyException.

$client->setSecret(string $key, string $value, string $type = 'text'): void
$client->deleteSecret(string $key): void
$client->deleteSecretVersion(string $key, int $version): void

A/B Testing

$results = $client->evaluateAb(
    keys: ['checkout-v2'],
    userId: 'user-abc',
    attributes: ['plan' => 'pro', 'country' => 'HU']
);
// ['checkout-v2' => true]

SSE Live Updates

// Blocking, and it does not return: this is the main loop of a long-running
// process. For CLI daemons and queue workers only - an FPM request handler
// should use TTL polling instead.
$client->connectStream(maxErrors: 3);

connectStream() syncs on every meaningful event and reconnects with exponential backoff (1s, 2s, 4s, capped at 30s). After maxErrors consecutive connection failures it falls back to TTL polling for a cooldown window - syncing on the configured ttl for the whole window, so configuration stays current - and then attempts the stream again. Only an unrecoverable condition ends the call, by throwing: rejected credentials or a suspended account.

Two deadlines keep a dead connection from looking healthy:

OptionDefaultWhat it bounds
sseConnectTimeout10 sestablishing the connection
sseIdleTimeout60 stime between bytes on a live stream; every byte resets it, keepalive comments included

sseReconnectCooldown controls the cooldown schedule in minutes - null for the default [5, 10, 20, 40, 60], an int for a fixed delay, or an array whose last entry repeats.

File-type secrets

getSecretFilePath() returns a path for secrets stored as files - TLS certificates, private keys, service-account JSON:

$certPath = $client->getSecretFilePath('TLS_CERT');

Each client creates its own directory under the system temp directory, with a name chosen by the runtime and mode 0700. The files inside are created 0600 by the creating syscall, with O_EXCL so nothing at the target path can redirect the write. Nothing in the path is derived from the key name or the value, so a path that ends up in a log line or a process listing reveals neither. Files are removed when the client is destroyed, and a process-exit hook clears anything left behind.

Workload Identity Federation

use WestyxNexus\WifConfig;

$client = NexusClient::create(new NexusConfig(
    baseUrl: 'https://yourslug.westyx.dev',
    apiKey:  '',
    wif:     new WifConfig(provider: 'auto'),
));

Supported providers: kubernetes, aws, aws_iam, gcp, azure, auto (auto-detects from environment).

ProviderAuto-detect signalHow it authenticates
kubernetesprojected SA token file existsreads /var/run/secrets/kubernetes.io/serviceaccount/token
awsAWS_WEB_IDENTITY_TOKEN_FILE set and the file exists (EKS IRSA)reads the file at that path
azure (Workload Identity)AZURE_FEDERATED_TOKEN_FILE set and the file exists (AKS)reads the projected federated token file
gcpmetadata.google.internal reachableGETs the GCE instance identity token
aws_iamnot auto-detected - select explicitly (resolvable AWS credentials alone, e.g. a dev laptop's ~/.aws/credentials, are too weak a signal)SigV4-signs an STS GetCallerIdentity request (never sent to AWS) that Nexus replays to prove your IAM role - works on ECS/Fargate, Lambda, and plain EC2

AWS IAM (non-EKS AWS compute)

use WestyxNexus\WifConfig;

$client = NexusClient::create(new NexusConfig(
    baseUrl: 'https://yourslug.westyx.dev',
    apiKey:  '',
    wif:     new WifConfig(
        provider:  'aws_iam',
        // awsRegion: 'eu-central-1', // optional - defaults to the standard AWS region chain, then EC2 IMDS
    ),
));

Requires the optional aws/aws-sdk-php package (its credential/region chains and SigV4 signer do the work):

composer require aws/aws-sdk-php

If the package is missing when you select aws_iam, the SDK throws with an actionable message naming that exact command.

Nothing else to configure: the SDK signs your service's own host (the base URL host) into the X-Nexus-Server-ID header inside the SigV4 signature, and Nexus verifies it against the host the exchange request arrived on. A captured signed request is therefore valid for that ONE service only - it cannot be replayed against another service or deployment. Credentials come from the standard AWS chain (task role, instance profile, env), and a fresh signature is produced on every session refresh. aws_iam is never auto-detected - select it explicitly.

Security note: baseUrl must use https:// - NexusClient::create() refuses plain-http endpoints (loopback/localhost excepted for development), since credentials travel on every request. When WIF is active, an expired session is refreshed transparently before every request (reads included); if the refresh fails, a WIF-only client fails closed with NexusSessionExpiredException rather than sending an empty API-key header. A static apiKey alongside WIF is used as a fallback only when you explicitly configure one.

Laravel

Laravel discovers the service provider automatically, so installing the package is the whole setup:

composer require westyx/nexus
NEXUS_BASE_URL=https://yourslug.westyx.dev
NEXUS_API_KEY=wxs_...

The client is then resolvable anywhere:

use WestyxNexus\NexusClient;

// Constructor injection
public function __construct(private readonly NexusClient $nexus) {}

// Or the container alias
$host = app('nexus')->getConfig('db_host');

Two things come from your container. The PSR-18 client, so Nexus traffic goes through the HTTP stack your application already configures. And the PSR-3 logger, so SDK diagnostics land in your application log with nothing to configure - point NEXUS_LOG_CHANNEL at a channel to keep them separate.

The binding is deferred and lazy: creating a client performs a network round-trip, and nothing happens until something resolves NexusClient.

Publish the config file to change anything beyond the environment variables:

php artisan vendor:publish --tag=nexus-config
// config/nexus.php
return [
    'base_url' => env('NEXUS_BASE_URL', ''),
    'api_key'  => env('NEXUS_API_KEY', ''),
    'ttl'      => (int) env('NEXUS_TTL', 60),
    'log_channel' => env('NEXUS_LOG_CHANNEL'),
    'sse' => [
        'connect_timeout'    => (int) env('NEXUS_SSE_CONNECT_TIMEOUT', 10),
        'idle_timeout'       => (int) env('NEXUS_SSE_IDLE_TIMEOUT', 60),
        'reconnect_cooldown' => null,
    ],
    'wif' => [
        'enabled'    => (bool) env('NEXUS_WIF_ENABLED', false),
        'provider'   => env('NEXUS_WIF_PROVIDER', 'auto'),
        'aws_region' => env('NEXUS_WIF_AWS_REGION', ''),
    ],
];

Publishing is optional - the defaults above apply either way. Supported on Laravel 11, 12 and 13.

Logging

Pass any PSR-3 logger and the SDK reports what it is doing:

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$monolog = new Logger('nexus');
$monolog->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG));

$client = NexusClient::create(new NexusConfig(
    baseUrl: 'https://yourslug.westyx.dev',
    apiKey:  getenv('NEXUS_API_KEY'),
    logger:  $monolog,
));
LevelMessages
debugnexus: sync completed, nexus: sync not modified, nexus: SSE event received, nexus: SSE stream ended
infonexus: client ready, nexus: WIF session established, nexus: SSE reconnecting
errornexus: refresh failed, serving the cached snapshot, nexus: SSE transport error, nexus: SSE max errors reached, falling back to TTL polling, nexus: polling sync failed

Messages are stable strings and values travel in the PSR-3 $context array, so a log aggregator can group and query them - nexus: sync completed arrives with configs, secrets, flags and key_type as fields rather than baked into the text.

Nothing derived from a secret is ever logged - not the value, and not the key name, which is itself part of what a secret store protects. A permanent test drives a sync carrying both a text and a file-type secret through a capturing logger at the most verbose level and fails if either value or either key name appears anywhere in the output.

With no logger configured the SDK is silent.

Error Handling

All exceptions extend WestyxNexus\Exceptions\NexusException.

use WestyxNexus\Exceptions\{
    NexusUnauthorizedException,
    NexusBillingException,
    NexusPublicKeyException,
    NexusNotFoundException,
    NexusRateLimitedException,
    NexusQuarantinedException,
    NexusSessionExpiredException,
};

try {
    $client->setSecret('KEY', 'value');
} catch (NexusPublicKeyException) {
    // public key cannot write
} catch (NexusRateLimitedException $e) {
    // retry after $e->retryAfterSeconds seconds
} catch (NexusQuarantinedException $e) {
    // do not retry until $e->expiresAt
} catch (NexusSessionExpiredException) {
    // WIF session expired and could not be refreshed (fail-closed)
}

OpenFeature Provider

WestyxNexus\OpenFeature\NexusProvider implements the OpenFeature PHP SDK provider interface. It ships in this package; install the OpenFeature SDK alongside it:

composer require westyx/nexus open-feature/sdk

Usage:

use OpenFeature\OpenFeatureAPI;
use WestyxNexus\NexusClient;
use WestyxNexus\NexusConfig;
use WestyxNexus\OpenFeature\NexusProvider;

$nexus = NexusClient::create(new NexusConfig(
    baseUrl: 'https://yourslug.westyx.dev',
    apiKey:  getenv('NEXUS_API_KEY'),
));

$api = OpenFeatureAPI::getInstance();
$api->setProvider(new NexusProvider($nexus));

$client  = $api->getClient();
$enabled = $client->getBooleanValue('dark-mode', false);
$apiUrl  = $client->getStringValue('api.url', 'https://default.example.com');

Per-user targeting

Pass a targeting key and the provider evaluates the flag for that identity through the AB Testing add-on, instead of reading the anonymous snapshot value:

use OpenFeature\implementation\flags\Attributes;
use OpenFeature\implementation\flags\EvaluationContext;

$context = new EvaluationContext('user-42', new Attributes([
    'plan'   => 'pro',
    'region' => 'eu-west',
]));

$details = $client->getBooleanDetails('new-checkout', false, $context);
// $details->getReason() === 'TARGETING_MATCH'

One request per identity, not per flag. The first targeted evaluation asks about every flag in the snapshot at once and memoises the answers, so the rest are served from memory. A flag set larger than the endpoint's 200-key limit is split across requests automatically.

Reasons, which is what your code should switch on rather than the value alone:

ReasonMeaning
STATICThe snapshot value. No targeting key was given, or the project has no AB Testing add-on.
CACHEDA memoised targeted result.
TARGETING_MATCHFreshly evaluated for this identity.
STALEThe evaluation request failed; the snapshot value was served. No error is attached, because a resolution error would return your default and report false for a flag that is genuinely on.
DEFAULT + FLAG_NOT_FOUNDThe snapshot does not define this flag. Nothing is sent, because the endpoint answers false for a key it does not know - which is indistinguishable from "exists and off".

Attribute values must be strings. Cohort rules compare them as strings, so a non-string context attribute is dropped and logged rather than converted: no conversion is lossless, and a coerced value that matches no rule is harder to diagnose than an absent one.

A targeted evaluation performs I/O on a memo miss. If a code path must not block, either pass no targeting key or call NexusClient::evaluateAb() directly.

Tuning:

use WestyxNexus\OpenFeature\NexusProviderOptions;

$api->setProvider(new NexusProvider($nexus, new NexusProviderOptions(
    targetingTtl:      30.0,   // seconds a memoised result stays fresh
    maxTargetingKeys:  10000,  // identities held before eviction
    addonSuppression:  300.0,  // seconds to stop calling after a 403
)));

Each value is validated at construction and the failure names the option. On lifetime: under PHP-FPM the provider lives for one request, so the TTL and the cap bound a single request's evaluations - the batching is where the benefit is. In a CLI daemon or queue worker, where the process outlives the work item, the TTL applies as written.

Diagnostics go to the logger the OpenFeature SDK's LoggerAwareTrait provides: $provider->setLogger($psr3Logger). Dropped attributes and a failed targeted evaluation are reported there.

Requirements

  • PHP 8.3+ - tested against 8.3, 8.4 and 8.5 on every commit
  • Composer
  • A PSR-18 HTTP client and PSR-17 factories. Any implementation works; most projects already have one. php-http/discovery locates it, or pass your own to create().
  • ext-curl - only for connectStream(). Everything else works without it.
  • aws/aws-sdk-php ^3.0 - only for the aws_iam WIF provider (composer require aws/aws-sdk-php).
  • illuminate/support ^11 | ^12 | ^13 - only for the Laravel service provider, which your Laravel application already provides.
  • open-feature/sdk ^2.0 - only for the OpenFeature provider (composer require open-feature/sdk).

Behaviour worth knowing

How the SDK treats the cases where more than one design is defensible. Version-to-version migration notes are in the CHANGELOG.

  1. Errors arrive as typed exceptions, not as HTTP responses. PSR-18 clients return 4xx and 5xx as ordinary responses rather than throwing, so the SDK checks every status itself and raises NexusException or one of its subclasses. Catch those around SDK calls, not your HTTP client's exceptions.
  2. The live-update stream uses ext-curl directly, because PSR-18 has no vocabulary for incremental delivery. Without the extension, connectStream() raises an error naming the requirement and TTL polling keeps working - losing live updates is a degradation, not a failure.
  3. Configuration is validated when NexusConfig is built. An unusable ttl, sseReconnectCooldown, sseConnectTimeout or sseIdleTimeout is rejected there, naming the option, the value and the array index, rather than being adjusted to something that works: a value quietly changed under you is a setting you cannot reason about from the code.
  4. Diagnostics go through PSR-3, into the logger you pass as NexusConfig::$logger. Secret values and secret key names never appear in log output, which a permanent test enforces against a capturing logger.
  5. The Laravel and OpenFeature integrations ship inside this package with illuminate/support and open-feature/sdk as Composer suggest entries. A project that uses neither installs nothing extra and loads neither class, because PHP autoloads lazily; a CI job asserts that a plain install pulls in no open-feature/* package. For OpenFeature this is also a constraint rather than a preference: a Composer package must sit at the root of its own git repository, so the provider cannot be distributed from a sub-directory of this one.
  6. Config values are not coerced to the requested type. Through the OpenFeature provider, a config holding the string "8080" resolves as TYPE_MISMATCH rather than as the number 8080, and 2.9 does not resolve as 2 - a rounded value reported as a success is a wrong number with nothing to indicate it. A whole-numbered float such as 5.0 does resolve as an integer, because JSON has a single number type.
  7. A targeted boolean evaluation reaches the network. Every other resolution reads the in-memory snapshot; a boolean evaluation carrying a targeting key calls the service on a memo miss, which is what a per-user answer costs. The reason field always says which kind of answer you received, and passing no targeting key keeps the resolution local.

License

MIT