traffman / sdk-php
Transport-agnostic PHP SDK for the Traffman API (content exchange network).
Requires
- php: ^8.1
- ext-json: *
Requires (Dev)
- nyholm/psr7: ^1.8
- php-http/discovery: ^1.20
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^1.1 || ^2.0
- psr/simple-cache: ^2.0 || ^3.0
- squizlabs/php_codesniffer: ^3.13
Suggests
- ext-curl: To use the built-in CurlTransport (the default when no PSR-18 client is available).
- ext-mbstring: To use ContentNormalizer for preparing the pushed content.
- php-http/discovery: To find the installed PSR-18 client and PSR-17 factories automatically.
- psr/http-client-implementation: To use Psr18Transport with any PSR-18 HTTP client (Guzzle, Symfony HttpClient, ...).
- psr/simple-cache: To use Psr16Cache as the storage of CacheMiddleware.
Provides
None
Conflicts
None
Replaces
None
README
PHP client for the Traffman API, the content exchange network for sites.
- Transport-agnostic. The SDK sends requests through its own small
TransportInterface. It ships a PSR-18 adapter and a dependency-free cURL transport, and a transport for any platform takes about 20 lines (see the WordPress example). - No runtime dependencies beyond PHP 8.1+ and
ext-json. - Every moving part is an interface: credentials, transport, cache, clock, visitor IP, environment description, middleware, the resources and the client itself.
declare(strict_types=1)everywhere, PHPStan level max, PSR-12.
Installation
composer require traffman/sdk-php
Quick start
use Traffman\Sdk\Credentials\Credentials; use Traffman\Sdk\TraffmanClient; $traffman = new TraffmanClient( credentials: new Credentials($apiKey, $apiSecret, $clientId), ); $iam = $traffman->client()->iam(); // client info and subscription $dashboard = $traffman->client()->dashboard();
When no transport is passed, the SDK uses a PSR-18 client found by php-http/discovery if one is installed,
and CurlTransport otherwise.
Transports
| Transport | When to use |
|---|---|
Transport\Psr18Transport |
Any PSR-18 client: Guzzle, Symfony HttpClient, Buzz, ... |
Transport\CurlTransport |
No dependencies, needs ext-curl |
Your own TransportInterface |
The platform HTTP API (WordPress wp_remote_request(), ...) |
Testing\MockTransport |
Tests: returns queued responses and records the requests |
use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Traffman\Sdk\Transport\Psr18Transport; $factory = new HttpFactory(); $transport = new Psr18Transport(new Client(['timeout' => 10, 'allow_redirects' => false]), $factory, $factory); $traffman = new TraffmanClient(credentials: $credentials, transport: $transport);
A transport must return 4xx and 5xx responses as a Response instead of throwing. It throws
TransportException only when no response was received. It must not follow redirects.
use Traffman\Sdk\Http\Request; use Traffman\Sdk\Http\Response; use Traffman\Sdk\Transport\TransportInterface; final class MyTransport implements TransportInterface { public function send(Request $request): Response { // $request->getMethod(), getUrl(), getHeaders(), getBody() return new Response($status, $headers, $body); } }
Configuration
use Traffman\Sdk\Config; new TraffmanClient( credentials: $credentials, // Credentials or a CredentialsProviderInterface transport: $transport, // TransportInterface config: new Config( baseUrl: 'https://api.traffman.com', // e.g. a staging or a local API apiVersion: 'v1', userAgent: 'my-plugin/1.0', headers: ['X-Timezone' => 'Europe/Kyiv'], ), middleware: [/* MiddlewareInterface */], userIpResolver: $resolver, // UserIpResolverInterface environmentProvider: $environment, // EnvironmentProviderInterface clock: $clock, // ClockInterface (PSR-20 compatible) );
Credentials
The API key and secret come from the Traffman account. The client ID arrives later, from the confirmation
webhook (see below), so it is optional until then. A CredentialsProviderInterface is called on every request.
It can read the credentials lazily from the platform storage, so a client confirmed in the meantime is picked up
without rebuilding the SDK client:
use Traffman\Sdk\Credentials\CallbackCredentialsProvider; $credentials = new CallbackCredentialsProvider( fn () => new Credentials($store->get('key'), $store->get('secret'), $store->get('client_id')), );
Visitor IP
The targeted content needs the IP of the site visitor. The SDK sends it, signed, in X-User-Client-IP.
The default ServerUserIpResolver trusts only REMOTE_ADDR. Behind a CDN or a reverse proxy, enable the
proxy headers explicitly. Clients can forge these headers when the request does not pass through the proxy.
use Traffman\Sdk\UserIp\ServerUserIpResolver; new TraffmanClient(credentials: $credentials, userIpResolver: ServerUserIpResolver::withProxyHeaders());
API
Client
use Traffman\Sdk\Enum\ClientType; use Traffman\Sdk\Enum\ContentLanguageMode; use Traffman\Sdk\Model\SettingsUpdate; $traffman->client()->confirm('https://example.com/traffman/confirm'); // starts the confirmation $traffman->client()->iam(ClientType::WORDPRESS); // + required/preferable plugin version $traffman->client()->dashboard(); // null fields are left unchanged, an empty list clears a setting $traffman->client()->updateSettings(new SettingsUpdate( contentLanguage: ContentLanguageMode::EXACT, contentTopicTags: ['travel', 'food'], blacklistDomains: ['example.org'], blacklistWords: ['casino'], ));
Content
use Traffman\Sdk\Content\ContentNormalizer; use Traffman\Sdk\Enum\StatisticPeriod; use Traffman\Sdk\Enum\StatisticType; use Traffman\Sdk\Model\ContentItem; $normalizer = new ContentNormalizer(); // HTML → plain text of the allowed length (needs ext-mbstring) $item = new ContentItem( id: (string) $post->id, url: $post->url, language: 'uk', title: $normalizer->normalizeTitle($post->title), content: $normalizer->normalize($post->html), updatedAt: $post->updatedAt, ); // $mapIds is the FULL list of the IDs that must exist: the API removes every item missing from it $result = $traffman->content()->push($allSelectedIds, [$item]); // up to 100 items $results = $traffman->content()->pushInBatches($allSelectedIds, $itemsIterable); // any number of items foreach ($result->failed as $failure) { // $failure->id, $failure->violations[]->path / ->message } $traffman->content()->pushedExternalIds(); $traffman->content()->statistics(StatisticType::CONTENT, StatisticPeriod::MONTH, 'Europe/Kyiv'); $traffman->content()->targeted(limit: 3, language: 'uk'); // only on a visitor request
Health checks (no credentials needed)
$traffman->health()->common(); $traffman->health()->checkConnection('https://example.com/traffman/health'); // the API calls the URL back $traffman->health()->checkContentUrl('https://example.com/');
Response models
The resources return immutable models (Traffman\Sdk\Model) with typed public properties. Every response model
also keeps its decoded data in $raw, including the fields the SDK does not map yet:
$dashboard = $traffman->client()->dashboard(); $dashboard->points->total; // typed $dashboard->raw['someNewField'] ?? null; // a field added to the API after the SDK release
Create the models with fromArray(), e.g. as the return value of a mocked resource interface in your tests:
$iam = ClientIam::fromArray(['id' => '...', 'subscription' => [/* ... */], /* ... */]);
The model constructors are @internal: a minor version may add arguments to them.
Endpoints not covered yet
$response = $traffman->api()->request('GET', 'clients/iam', query: ['type' => 'wordpress']); $data = $response->toArray();
Client confirmation (webhooks)
client()->confirm($callbackUrl)asks the API to confirm the client for the host of$callbackUrl.- The API calls
$callbackUrlwithtype: validationand expects the signature of its token. - The API calls
$callbackUrlagain withtype: successand the client ID. Store the ID: most calls need it.
The SDK handles both calls. Expose the handler on a public route of your platform:
$handler = $traffman->createClientConfirmWebhookHandler( 'https://example.com/traffman/confirm', // the same URL as passed to confirm() fn (string $clientId) => $store->set('client_id', $clientId), ); $response = $handler->handleRaw(file_get_contents('php://input')); http_response_code($response->statusCode); foreach ($response->getHeaders() as $name => $value) { header("$name: $value"); } echo $response->getContent();
createHealthConnectionWebhookHandler() answers health()->checkConnection() in the same way.
Content blocks
The site embeds a script that fills the content blocks on its pages:
$scriptUrl = $traffman->contentBlocks()->buildScriptUrl( requestUrl: 'https://example.com/traffman/blocks', // your endpoint the script posts the block IDs to blockClass: 'traffman-block', formData: ['action' => 'traffman-blocks'], language: 'uk', extraQuery: ['ver' => '1.0.0'], );
Your endpoint gets the targeted content and signs every item for its block:
$items = $traffman->content()->targeted(count($blockIds), $language)->items; $blocks = []; foreach ($blockIds as $index => $blockId) { if (!isset($items[$index])) { continue; } $blocks[$blockId] = [ 'id' => $items[$index]->id, 'content' => htmlspecialchars($items[$index]->content), 'sig' => $traffman->contentBlocks()->signRedirect($blockId, $items[$index]->id), ]; } echo json_encode(['success' => true, 'blocks' => $blocks]);
Middleware
Middleware wraps every request. It runs in the given order before the request is signed, so it may change the request freely, and a retried request gets a fresh signature.
use Traffman\Sdk\Cache\Psr16Cache; use Traffman\Sdk\Middleware\CacheMiddleware; use Traffman\Sdk\Middleware\CallableMiddleware; use Traffman\Sdk\Middleware\HeadersMiddleware; use Traffman\Sdk\Middleware\RetryMiddleware; new TraffmanClient(credentials: $credentials, middleware: [ new CallableMiddleware(function ($request, $next) use ($logger) { $response = $next->handle($request); $logger->info($request->getMethod() . ' ' . $request->getUrl() . ' → ' . $response->getStatusCode()); return $response; }), new CacheMiddleware(new Psr16Cache($psr16Cache), ttl: 300), // iam, dashboard, statistics new RetryMiddleware(maxRetries: 2), // network errors, 429, 502–504 new HeadersMiddleware(['X-Timezone' => 'Europe/Kyiv']), ]);
The middleware can see the operation name in $request->getAttribute(RequestAttribute::OPERATION).
Http\Operation lists the names.
Errors
All SDK exceptions implement Exception\TraffmanExceptionInterface.
| Exception | When |
|---|---|
TransportException |
No response: network error, timeout, DNS, TLS |
ApiException |
Any 4xx/5xx; getStatusCode(), getErrorCode(), getPayload(), getResponse() |
↳ BadRequestException |
400. The confirmation error codes are in ErrorCode |
↳↳ ValidationException |
400 request.validation.failed; getViolations() |
↳ AuthenticationException |
401: wrong credentials or signature, or the client is not confirmed or inactive |
↳ AccessDeniedException |
403 |
↳ NotFoundException |
404 |
↳ RateLimitException |
429; getRetryAfter() |
↳ ServerException |
5xx |
UnexpectedResponseException |
A 2xx response with an unexpected body |
MissingCredentialsException |
The call needs credentials or the client ID, and they are not set |
InvalidArgumentException |
Invalid argument, detected before sending |
Testing your integration
Depend on TraffmanClientInterface (or a resource interface) and use MockTransport:
use Traffman\Sdk\Testing\MockTransport; $transport = new MockTransport(MockTransport::json(['items' => ['1', '2']])); $traffman = new TraffmanClient(credentials: $credentials, transport: $transport); $traffman->content()->pushedExternalIds(); // ['1', '2'] $transport->getLastRequest()->getUrl(); // .../content/pushed/external-ids
Development
composer install
composer check # phpcs + phpstan + phpunit
The integration tests (tests/Integration) run against a real API only when TRAFFMAN_API_URL is set.
They are read-only.
Support
Report bugs and request features in GitHub issues. For anything else, contact Traffman.
License
MIT