allytech / traitx-sdk
TraitX decision SDK — evaluate risk events against your policy chain and get allow / challenge / deny.
Requires
- php: >=8.1
- ext-json: *
Suggests
- ext-curl: Used when available; the SDK falls back to the HTTP stream wrapper without it.
This package is not auto-updated.
Last update: 2026-08-19 19:37:06 UTC
README
Turns a TraitX risk evaluation into one of three answers: allow, challenge,
deny. PHP 8.1+, no Composer dependencies — cURL (with a stream fallback) and
ext-json, so the SDK cannot pin an HTTP client version against your application's.
Wire contract and decision algorithm: SPEC.md.
Install
composer require allytech/traitx-sdk
To run straight from a checkout with no Composer install, require the bundled
autoload.php instead.
Quick start
use TraitX\Action; use TraitX\Config; use TraitX\EventUser; use TraitX\RequestContext; use TraitX\TraitXClient; $traitx = new TraitXClient(new Config( baseUrl: getenv('TRAITX_BASE_URL'), // https://traitx.allytech.sa apiKey: getenv('TRAITX_PRIVATE_KEY'), // trx_pvk_… applicationId: getenv('TRAITX_APPLICATION_ID') ?: null, timeoutMs: 2500, failureMode: Action::Allow, // TraitX down → do not block logins )); // or simply: new TraitXClient(Config::fromEnvironment()); $decision = $traitx->evaluateLogin( requestId: $_SERVER['HTTP_X_TRAITX_REQUEST_ID'] ?? '', // from the browser collector user: new EventUser(id: $user->id, email: $user->email), context: RequestContext::fromServer($_SERVER), ); if ($decision->isDenied()) { http_response_code(403); echo json_encode(['error' => 'access denied', 'reference' => $decision->requestId]); return; } if ($decision->requiresChallenge()) { sendOtp($user); echo json_encode(['next' => 'otp', 'signals' => $decision->signals]); return; } echo json_encode(['token' => issueToken($user)]);
RequestContext::fromServer($_SERVER) reads the end user's IP and headers, honouring
X-Forwarded-For. Only trust that header when your own edge sets it — if clients can
reach PHP directly they can forge it, so strip or overwrite it at the proxy.
For Laravel or Symfony, use RequestContext::fromHeaders($request->headers->all(), $request->ip()).
Event helpers
$traitx->evaluateLogin($requestId, user: $user, context: $context, session: $session); $traitx->evaluateRegistration($requestId, user: $user, context: $context); $traitx->evaluatePasswordReset($requestId, user: $user, context: $context); $traitx->evaluateProfileUpdate($requestId, user: $user, context: $context); $traitx->reportChallengeOutcome($requestId, passed: $otpOk, user: $user); $traitx->evaluateTransaction( requestId: $requestId, amount: 5000, currency: 'SAR', payeeId: 'payee_88213', user: $user, context: $context, attributes: [ 'merchant.mcc' => '5967', // a dotted key is a literal key name 'payment.channel' => 'mada', ], );
Anything else goes through evaluate() with an explicit RiskEvent:
use TraitX\EventType; use TraitX\RiskEvent; $decision = $traitx->evaluate(new RiskEvent( requestId: $requestId, type: EventType::Custom, attributes: ['promo_code' => $code], ));
Reading a decision
$decision->action // Action::Allow | Action::Challenge | Action::Deny ← act on this $decision->reason // Reason::PolicyMatch | Shadow | ScoreThreshold | … $decision->score // 0–100 $decision->riskLevel // RiskLevel::Low | Medium | High | Critical $decision->signals // ['bot_behavior', 'tor_ip'] $decision->matchedPolicies // [MatchedPolicy{id, name, action, passThrough}] $decision->enforced // false when the deciding policy is shadow-mode $decision->observedAction // what the chain would have done $decision->degraded // true when TraitX was unreachable or rejected the call $decision->latencyMs $decision->raw // full response body $decision->toArray() // log-friendly $decision->isAllowed(); $decision->requiresChallenge(); $decision->isDenied(); $decision->hasDeviceContext(); // false → IP enrichment missing, IP policies unreachable
Branch on isAllowed(), never on !isDenied() — the latter lets challenges through
unchallenged, which is the most common way this integration goes wrong.
Shadow mode
A policy deployed with pass_through: true evaluates and logs but does not enforce. The
SDK returns Action::Allow with enforced === false and observedAction set to what
would have happened:
if (!$decision->enforced && $decision->observedAction !== Action::Allow) { $logger->info('traitx shadow', [ 'would' => $decision->observedAction?->value, 'score' => $decision->score, ]); }
Failure behaviour
evaluate() never throws for network or API problems. It returns a decision with
degraded === true and the configured fallback:
| Situation | Option | Default |
|---|---|---|
| Timeout, connection error, 5xx, 429, open breaker | failureMode |
Action::Allow |
| 401 / 400 / 403 — bad key or bad payload | clientErrorMode |
Action::Allow |
Both default to allow deliberately: a rotated key must not lock every customer out of
checkout. Set them to Action::Challenge or Action::Deny if your risk appetite says
otherwise — that is a business decision, so the SDK makes you state it.
ValidationException is thrown for events the SDK refuses to send (missing
requestId, $before_all as a type). Those are bugs in calling code.
A note on PHP-FPM
The circuit breaker lives in the PHP process. With a fresh worker per request it protects
long-lived workers and CLI/queue processes, not one-shot requests. Where that matters,
keep timeoutMs low and maxRetries at 0–1 — in a share-nothing runtime that is the real
latency guard:
new Config(baseUrl: …, apiKey: …, timeoutMs: 1500, maxRetries: 1);
Laravel middleware
final class TraitXContext { public function __construct(private readonly TraitXClient $traitx) {} public function handle(Request $request, Closure $next): Response { $request->attributes->set('traitx_request_id', $request->header('X-TraitX-Request-Id', '')); $request->attributes->set('traitx_context', RequestContext::fromHeaders($request->headers->all(), $request->ip())); return $next($request); } }
Register the client as a singleton so the breaker survives across a worker's requests:
$this->app->singleton(TraitXClient::class, fn () => new TraitXClient(new Config( baseUrl: config('traitx.base_url'), apiKey: config('traitx.private_key'), applicationId: config('traitx.application_id'), )));
Management API (optional)
Policies and lists authenticate with a portal JWT, not the private key. These calls
throw ApiException rather than degrading.
$traitx->management->withToken(getenv('TRAITX_PORTAL_JWT')); $audit = $traitx->management->auditPolicies(); $logger->info('traitx policies', [ 'live' => count($audit['live']), 'shadow' => count($audit['shadow']), 'disabled' => count($audit['disabled']), ]); $traitx->management->addListEntry( listId: $blockedDevicesListId, value: $visitorId, comment: "chargeback {$caseId}", expiresAt: new DateTimeImmutable('+90 days'), );
Self-check
TRAITX_BASE_URL=https://traitx.allytech.sa \ TRAITX_PRIVATE_KEY=trx_pvk_… \ TRAITX_APPLICATION_ID=ccf2d2a0-… \ php bin/traitx-doctor req_abc123
Tests
php tests/conformance.php
Runs conformance/vectors.json — the shared fixture
every language binding must satisfy — plus serialisation, validation, breaker and
redaction checks. Dependency-free, so it runs offline from a bare checkout.