westyx / nexus
Westyx Nexus PHP SDK - secrets, configs and feature flags
Requires
- php: >=8.3
- php-http/discovery: ^1.20
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^3.0
Requires (Dev)
- aws/aws-sdk-php: ^3.0
- friendsofphp/php-cs-fixer: ^3.95
- guzzlehttp/guzzle: ^7.0
- illuminate/config: ^11.0 || ^12.0 || ^13.0
- illuminate/container: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- open-feature/sdk: ^2.0
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.0
- vlucas/phpdotenv: ^5.6
Suggests
- ext-curl: Required only for the SSE live-update stream (NexusClient::connectStream). Every other operation works without it.
- aws/aws-sdk-php: Required only for the aws_iam WIF provider (SigV4-signed STS GetCallerIdentity). Install with: composer require aws/aws-sdk-php
- guzzlehttp/guzzle: A PSR-18 HTTP client implementation. Any PSR-18 client works - install one if your project does not already provide one.
- illuminate/support: Required only for the Laravel service provider (WestyxNexus\Laravel\NexusServiceProvider), which Laravel auto-discovers.
- open-feature/sdk: Required only for the OpenFeature provider (WestyxNexus\OpenFeature\NexusProvider). Install with: composer require open-feature/sdk
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 atargetingKeyis resolved for that identity through the AB Testing add-on, and reportsTARGETING_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 withcomposer require westyx/nexuspluscomposer require open-feature/sdk. It is the same arrangement as the Laravel service provider: asuggest, 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 fromevaluateAb()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. Anullvalue is treated as unset. - Numbers resolve as numbers -
resolveIntegerValue()andresolveFloatValue()reportTYPE_MISMATCHinstead 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/nexusbrings 6 packages and about 1 MB. See HTTP client. - Laravel service provider -
composer require westyx/nexusin 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
$contextarray. 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 created0600at 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 Modifiedand 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-Afterand 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_iamWIF 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 STSGetCallerIdentityrequest (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 signedX-Nexus-Server-IDis 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 optionalaws/aws-sdk-phppackage. See AWS IAM.- Security hardening -
NexusClient::create()rejects plain-httpbase URLs (loopback excepted); WIF reads now transparently refresh an expired session and fail closed (a WIF-only client never falls back to an emptyX-Nexus-API-Key, throwingNexusSessionExpiredExceptioninstead); 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_iamsigned-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
gcpprovider now fetches a real Google-signed OIDC identity token from the GCE metadata server. It previously read theGOOGLE_APPLICATION_CREDENTIALSkey 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 forgcp, andAZURE_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
NexusConfigdoc-comment from the non-existenthttps://<slug>.api.westyx.devto the correcthttps://<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
Providerinterface, wrapping an existingNexusClient. 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.
sseReconnectCooldowncontrols 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:
| Option | Default | What it bounds |
|---|---|---|
sseConnectTimeout | 10 s | establishing the connection |
sseIdleTimeout | 60 s | time 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).
| Provider | Auto-detect signal | How it authenticates |
|---|---|---|
kubernetes | projected SA token file exists | reads /var/run/secrets/kubernetes.io/serviceaccount/token |
aws | AWS_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 |
gcp | metadata.google.internal reachable | GETs the GCE instance identity token |
aws_iam | not 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:
baseUrlmust usehttps://-NexusClient::create()refuses plain-httpendpoints (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 withNexusSessionExpiredExceptionrather than sending an empty API-key header. A staticapiKeyalongside 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,
));
| Level | Messages |
|---|---|
debug | nexus: sync completed, nexus: sync not modified, nexus: SSE event received, nexus: SSE stream ended |
info | nexus: client ready, nexus: WIF session established, nexus: SSE reconnecting |
error | nexus: 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:
| Reason | Meaning |
|---|---|
STATIC | The snapshot value. No targeting key was given, or the project has no AB Testing add-on. |
CACHED | A memoised targeted result. |
TARGETING_MATCH | Freshly evaluated for this identity. |
STALE | The 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_FOUND | The 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/discoverylocates it, or pass your own tocreate(). ext-curl- only forconnectStream(). Everything else works without it.aws/aws-sdk-php^3.0 - only for theaws_iamWIF 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.
- 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
NexusExceptionor one of its subclasses. Catch those around SDK calls, not your HTTP client's exceptions. - The live-update stream uses
ext-curldirectly, 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. - Configuration is validated when
NexusConfigis built. An unusablettl,sseReconnectCooldown,sseConnectTimeoutorsseIdleTimeoutis 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. - 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. - The Laravel and OpenFeature integrations ship inside this package with
illuminate/supportandopen-feature/sdkas Composersuggestentries. 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 noopen-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. - Config values are not coerced to the requested type. Through the OpenFeature provider, a
config holding the string
"8080"resolves asTYPE_MISMATCHrather than as the number8080, and2.9does not resolve as2- a rounded value reported as a success is a wrong number with nothing to indicate it. A whole-numbered float such as5.0does resolve as an integer, because JSON has a single number type. - 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
reasonfield always says which kind of answer you received, and passing no targeting key keeps the resolution local.
License
MIT