allytech/traitx-sdk

TraitX decision SDK — evaluate risk events against your policy chain and get allow / challenge / deny.

Maintainers

Package info

github.com/traitx-allytech/traitx-sdk-php

pkg:composer/allytech/traitx-sdk

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-18 20:58 UTC

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.