Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43c2c4c
fix: cross-SDK test compatibility fixes
joalves Jan 19, 2026
cc89d5a
fix: PHP 8.4 deprecation and test expectations
joalves Jan 20, 2026
f4a2515
fix: queue exposure when variables exist regardless of key
joalves Jan 20, 2026
d88e1f2
fix: use correct traffic seed property names for eligibility check
joalves Jan 22, 2026
ecabe41
feat: add comprehensive test coverage improvements
joalves Jan 27, 2026
9168988
docs: add Laravel, Symfony examples and async patterns to PHP SDK
joalves Jan 30, 2026
ab7c04f
fix: correct HTTPClient class name in PHP SDK docs
joalves Jan 30, 2026
4b575fd
test: add canonical test parity (169 → 336 tests)
joalves Feb 6, 2026
0d51ff6
fix: audit fixes for operator correctness and async interface support
joalves Feb 24, 2026
e01fbbe
docs: restructure README to match standard SDK documentation structure
joalves Feb 24, 2026
d82b09e
feat: add createSimple() with correct parameter ordering
joalves Feb 26, 2026
235ab4a
docs: update README documentation
joalves Mar 9, 2026
b8fa6f9
feat(php-sdk): add getCustomFieldKeys, getCustomFieldValueType, ready…
joalves Mar 15, 2026
1f04e03
feat(php-sdk): add isFinalized/finalize aliases and standardize error…
joalves Mar 15, 2026
eb7aa19
fix: read methods return safe defaults instead of throwing when not r…
joalves Mar 15, 2026
9933447
fix(php-sdk): standardize error messages in Context
joalves Mar 16, 2026
e88af05
fix: update setUnit exception messages to use double quotes and corre…
joalves Mar 17, 2026
e9e6bba
feat: cross-SDK consistency fixes — all 201 scenarios passing
joalves Mar 17, 2026
1ebd865
refactor: rename ContextEventHandler to ContextPublisher
joalves Mar 18, 2026
ed9cc97
fix: address coderabbit review issues
joalves Mar 18, 2026
5b685c2
feat: add setContextPublisher() method, deprecate setContextEventHand…
joalves Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ composer.lock
coverage.xml
.phpunit.cache
phpunit.xml

.claude/
.DS_Store
AUDIT_REPORT.md
FIXES_IMPLEMENTED.md
690 changes: 532 additions & 158 deletions README.md

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5.26"
"phpunit/phpunit": "^9.5.26",
"react/http": "^1.9",
"react/async": "^4.0",
"react/promise": "^3.0"
},
"suggest": {
"react/http": "Required for async HTTP support with ReactPHP",
"react/async": "Required for sync-over-async operations with ReactPHP",
"react/promise": "Required for async operations"
},
Comment on lines +11 to 20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether ReactPHP symbols are used by runtime source code.
# Expected:
# - If matches exist in src/, these are runtime dependencies and should not be dev-only.

rg -n --type=php -C2 '\bReact\\' src
rg -n --type=php -C2 '\bPromiseInterface\b|\bDeferred\b|\bawait\s*\(' src

python - <<'PY'
import json
with open("composer.json") as f:
    c = json.load(f)
print("require keys:", sorted(c.get("require", {}).keys()))
print("require-dev keys:", sorted(c.get("require-dev", {}).keys()))
print("suggest keys:", sorted(c.get("suggest", {}).keys()))
PY

Repository: absmartly/php-sdk

Length of output: 9434


ReactPHP packages must be moved from require-dev to require.

Multiple runtime classes in src/ have hard dependencies on ReactPHP packages: ReactHttpClient imports React\Http\Browser and React\Async\await; Client, AsyncClientInterface, ABsmartly, and async context classes all use React\Promise\PromiseInterface in their public method signatures. Consumers installing this library without the --dev flag will encounter fatal class/interface-not-found errors when instantiating async components. Move react/http, react/async, and react/promise to the require section, or fully hard-isolate async code so autoloaded runtime classes do not depend on these packages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composer.json` around lines 11 - 20, The Composer manifest currently lists
react/http, react/async, and react/promise as dev dependencies but multiple
runtime classes (e.g., ReactHttpClient which imports React\Http\Browser and
React\Async\await, public APIs like Client, AsyncClientInterface, ABsmartly, and
your async context classes that reference React\Promise\PromiseInterface)
require them at runtime; update composer.json by moving "react/http",
"react/async", and "react/promise" from require-dev into the "require" section
(or alternatively fully isolate async-only code behind a separate package or
lazy-loaded wrapper so none of the autoloaded runtime classes reference these
symbols), ensuring consumers installing without --dev will have these packages
available.

"license": "MIT",
"autoload": {
Expand All @@ -27,5 +35,8 @@
"email": "ayesh@aye.sh"
}
],
"keywords": ["absmartly"]
"keywords": ["absmartly"],
"scripts": {
"test": "phpunit"
}
}
164 changes: 164 additions & 0 deletions src/ABsmartly.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

namespace ABSmartly\SDK;

use ABSmartly\SDK\Client\Client;
use ABSmartly\SDK\Client\ClientConfig;
use ABSmartly\SDK\Context\AsyncContextDataProvider;
use ABSmartly\SDK\Context\Context;
use ABSmartly\SDK\Context\ContextConfig;
use ABSmartly\SDK\Context\ContextData;
use ABSmartly\SDK\Context\ContextDataProvider;
use ABSmartly\SDK\Context\ContextPublisher;
use ABSmartly\SDK\Context\ContextEventLogger;
use ABSmartly\SDK\Http\HTTPClient;
use React\Promise\PromiseInterface;

use function React\Promise\resolve;

class ABsmartly {

private Client $client;
private ContextPublisher $handler;
private ContextDataProvider $provider;
private ?ContextEventLogger $eventLogger;

public function __construct(Config $config) {
$this->client = $config->getClient();
$this->provider = $config->getContextDataProvider();
$this->handler = $config->getContextEventHandler();
$this->eventLogger = $config->getContextEventLogger();
}

/**
* @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io".
* @param string $apiKey API key which can be found on the Web Console.
* @param string $application Name of the application where the SDK is installed. Applications are created on the
* Web Console and should match the applications where your experiments will be running.
* @param string $environment Environment of the platform where the SDK is installed. Environments are created on
* the Web Console and should match the available environments in your infrastructure.
* @param int $retries The number of retries before the SDK stops trying to connect.
* @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect.
* @param callable|null $eventLogger A callback function which runs after SDK events.
* @return ABsmartly ABsmartly instance created using the credentials and details above.
*/
public static function createSimple(
string $endpoint,
string $apiKey,
string $application,
string $environment,
int $retries = 5,
int $timeout = 3000,
?callable $eventLogger = null
): ABsmartly {
$clientConfig = new ClientConfig(
$endpoint,
$apiKey,
$application,
$environment,
);
$clientConfig->setRetries($retries);
$clientConfig->setTimeout($timeout);

$client = new Client($clientConfig, new HTTPClient());
$sdkConfig = new Config($client);
if ($eventLogger !== null) {
$sdkConfig->setContextEventLogger(new \ABSmartly\SDK\Context\ContextEventLoggerCallback($eventLogger));
}
return new ABsmartly($sdkConfig);
}

/**
* @deprecated Use createSimple() instead. This method has $environment and $application in the wrong order
* relative to ClientConfig::__construct(). createSimple() fixes this with the correct order:
* endpoint, apiKey, application, environment.
*
* @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io".
* @param string $apiKey API key which can be found on the Web Console.
* @param string $environment Environment of the platform where the SDK is installed. Environments are created on
* the Web Console and should match the available environments in your infrastructure.
* @param string $application Name of the application where the SDK is installed. Applications are created on the
* Web Console and should match the applications where your experiments will be running.
* @param int $retries The number of retries before the SDK stops trying to connect.
* @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect.
* @param callable|null $eventLogger A callback function which runs after SDK events.
* @return ABsmartly ABsmartly instance created using the credentials and details above.
*/
public static function createWithDefaults(
string $endpoint,
string $apiKey,
string $environment,
string $application,
int $retries = 5,
int $timeout = 3000,
?callable $eventLogger = null
): ABsmartly {

$clientConfig = new ClientConfig(
$endpoint,
$apiKey,
$application,
$environment,
);
$clientConfig->setRetries($retries);
$clientConfig->setTimeout($timeout);

$client = new Client($clientConfig, new HTTPClient());
$sdkConfig = new Config($client);
if ($eventLogger !== null) {
$sdkConfig->setContextEventLogger(new \ABSmartly\SDK\Context\ContextEventLoggerCallback($eventLogger));
}
return new ABsmartly($sdkConfig);
}

public function createContext(ContextConfig $contextConfig): Context {
$this->applyEventLogger($contextConfig);
return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler);
}

public function createContextWithData(ContextConfig $contextConfig, ContextData $contextData): Context {
$this->applyEventLogger($contextConfig);
return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler, $contextData);
}

private function applyEventLogger(ContextConfig $contextConfig): void {
if ($this->eventLogger !== null && $contextConfig->getEventLogger() === null) {
$contextConfig->setEventLogger($this->eventLogger);
}
}

public function createContextAsync(ContextConfig $contextConfig): PromiseInterface {
$this->applyEventLogger($contextConfig);
if (!$this->provider instanceof AsyncContextDataProvider) {
return resolve($this->createContext($contextConfig));
}

return $this->provider->getContextDataAsync()
->then(fn($data) => $this->createContextWithData($contextConfig, $data));
}

public function createContextPending(ContextConfig $contextConfig): array {
$this->applyEventLogger($contextConfig);
$context = Context::createPending($this, $contextConfig, $this->provider, $this->handler);

if (!$this->provider instanceof AsyncContextDataProvider) {
$promise = resolve(null)->then(function() use ($context) {
$data = $this->provider->getContextData();
$context->setContextData($data);
return $context;
});
} else {
$promise = $this->provider->getContextDataAsync()
->then(function($data) use ($context) {
$context->setContextData($data);
return $context;
});
}

return ['context' => $context, 'promise' => $promise];
}

public function close(): void {
$this->client->close();
}
}
9 changes: 5 additions & 4 deletions src/Assignment.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ class Assignment {
public int $iteration = 0;
public int $fullOnVariant = 0;
public string $name = '';
public ?string $unitType;
public array $trafficSplit;
public ?string $unitType = null;
public array $trafficSplit = [];
public int $variant = 0;
public bool $assigned = false;
public bool $overridden = false;
Expand All @@ -19,8 +19,9 @@ class Assignment {
public bool $custom = false;

public bool $audienceMismatch = false;
public stdClass $variables;
public ?stdClass $variables = null;

public bool $exposed;
public bool $exposed = false;
public int $attrsSeq = 0;
}

11 changes: 11 additions & 0 deletions src/Client/AsyncClientInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace ABSmartly\SDK\Client;

use ABSmartly\SDK\PublishEvent;
use React\Promise\PromiseInterface;

interface AsyncClientInterface extends ClientInterface {
public function getContextDataAsync(): PromiseInterface;
public function publishAsync(PublishEvent $publishEvent): PromiseInterface;
}
56 changes: 47 additions & 9 deletions src/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,38 @@
namespace ABSmartly\SDK\Client;

use ABSmartly\SDK\Context\ContextData;
use ABSmartly\SDK\Http\HttpClientInterface;
use ABSmartly\SDK\Http\AsyncHttpClientInterface;
use ABSmartly\SDK\Http\HTTPClient;
use ABSmartly\SDK\PublishEvent;
use React\Promise\PromiseInterface;

use function json_decode;
use function json_encode;
use function React\Promise\resolve;
use function rtrim;

use const JSON_THROW_ON_ERROR;

class Client {
class Client implements AsyncClientInterface {
protected const VERSION = '1.0.3';
private HTTPClient $httpClient;
private HttpClientInterface $httpClient;
private string $url;
private array $query;
private array $headers;

public function __construct(ClientConfig $clientConfig, ?HTTPClient $HTTPClient = null) {
public function __construct(ClientConfig $clientConfig, ?HttpClientInterface $HTTPClient = null) {
if (!$HTTPClient) {
$HTTPClient = new HTTPClient();
}
$this->httpClient = $HTTPClient;
$this->httpClient->timeout = $clientConfig->getTimeout();
$this->httpClient->retries = $clientConfig->getRetries();

if (property_exists($this->httpClient, 'timeout')) {
$this->httpClient->timeout = $clientConfig->getTimeout();
}
if (property_exists($this->httpClient, 'retries')) {
$this->httpClient->retries = $clientConfig->getRetries();
}

$this->url = rtrim($clientConfig->getEndpoint(), '/') .'/context';
$this->query = [
Expand All @@ -45,23 +54,48 @@ public function __construct(ClientConfig $clientConfig, ?HTTPClient $HTTPClient


private function authRequest(): void {

if (empty($this->headers['X-API-Key'])) {
throw new \ABSmartly\SDK\Exception\RuntimeException(
'API key is not configured. Please set a valid API key in ClientConfig.'
);
}
}

public function getContextData(): ContextData {
$this->authRequest();
$response = $this->httpClient->get($this->url, $this->query, $this->headers);
$response = $this->decode($response->content);
return new ContextData($response->experiments);
$decoded = $this->decode($response->content);
return new ContextData($decoded->experiments);
}

public function getContextDataAsync(): PromiseInterface {
if (!$this->httpClient instanceof AsyncHttpClientInterface) {
return resolve($this->getContextData());
}

$this->authRequest();
return $this->httpClient
->getAsync($this->url, $this->query, $this->headers)
->then(fn($response) => new ContextData($this->decode($response->content)->experiments));
}

public function publish(PublishEvent $publishEvent): void {
$data = $this->encode($publishEvent);
$this->httpClient->put($this->url, $this->query, $this->headers, $data);
}
Comment on lines 82 to 85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate the API key in publish() as well.

authRequest() currently only guards context-data fetches. Contexts built from pre-fetched data/pending flows can reach publish() without ever calling getContextData(), so a blank API key still produces outbound PUTs.

Proposed fix
 public function publish(PublishEvent $publishEvent): void {
+	$this->authRequest();
 	$data = $this->encode($publishEvent);
 	$this->httpClient->put($this->url, $this->query, $this->headers, $data);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Client/Client.php` around lines 82 - 85, The publish() method allows
outbound PUTs without validating the API key; update Client::publish to perform
the same API-key validation as authRequest()/getContextData() by either calling
the existing authRequest() guard or explicitly checking the stored API key
before encoding and calling $this->httpClient->put; if the API key is
missing/invalid, throw or abort the publish operation (same behavior as
authRequest()) to prevent blank-key requests from being sent.


public function publishAsync(PublishEvent $publishEvent): PromiseInterface {
if (!$this->httpClient instanceof AsyncHttpClientInterface) {
$this->publish($publishEvent);
return resolve(null);
}

$data = $this->encode($publishEvent);
return $this->httpClient->putAsync($this->url, $this->query, $this->headers, $data);
}
Comment on lines +71 to +95
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In ReactPHP's Promise library, if PHP code throws before React\Promise\resolve(...)returns, does the caller receive a rejectedPromiseInterface or a synchronous exception?

💡 Result:

You get a synchronous exception, not a rejected PromiseInterface.

React\Promise\resolve($promiseOrValue) does not wrap the caller’s code in a try/catch; it just inspects the value and returns either the original promise/thenable-wrapping promise or a fulfilled promise. So if something throws before resolve(...) can return (e.g. while computing the argument expression), there is no promise to reject—the exception bubbles normally. [1]

Only exceptions thrown inside a promise resolver/canceller (i.e., inside new React\Promise\Promise(function ($resolve, $reject) { ... })) are caught and turned into a rejected promise. [2]

Sources: [1] React\Promise\resolve() implementation, [2] ReactPHP Promise docs on resolver/canceller exceptions.


🏁 Script executed:

cd src/Client && head -100 Client.php | cat -n

Repository: absmartly/php-sdk

Length of output: 3848


🏁 Script executed:

rg "authRequest" --type php -B 2 -A 2

Repository: absmartly/php-sdk

Length of output: 852


🏁 Script executed:

rg "publish|getContextData" --type php -A 3 | head -80

Repository: absmartly/php-sdk

Length of output: 4776


🏁 Script executed:

rg "publish\(\)" tests/ --type php -B 3 -A 3 | head -100

Repository: absmartly/php-sdk

Length of output: 5623


🏁 Script executed:

cat -n tests/Client/ClientAsyncTest.php

Repository: absmartly/php-sdk

Length of output: 4835


🏁 Script executed:

rg "authRequest|X-API-Key|API key|RuntimeException" tests/ --type php -B 2 -A 2

Repository: absmartly/php-sdk

Length of output: 5421


🏁 Script executed:

rg "publish" src/ --type php -B 3 -A 3 | grep -A 5 -B 5 "authRequest"

Repository: absmartly/php-sdk

Length of output: 43


🏁 Script executed:

rg "getContextData|publish" src/Client/Client.php -B 2 -A 8

Repository: absmartly/php-sdk

Length of output: 1386


🏁 Script executed:

rg "API key|getApiKey" tests/ --type php -B 2 -A 2

Repository: absmartly/php-sdk

Length of output: 580


🏁 Script executed:

rg "publish.*auth|auth.*publish" src/ tests/ --type php -i -B 2 -A 2

Repository: absmartly/php-sdk

Length of output: 43


🏁 Script executed:

cat -n src/Client/AsyncClientInterface.php

Repository: absmartly/php-sdk

Length of output: 440


🏁 Script executed:

grep -r "getApiKey\|API key" src/Client/ --include="*.php" -B 2 -A 2

Repository: absmartly/php-sdk

Length of output: 1310


Wrap synchronous operations in try/catch to prevent promise contract violations.

Both async methods execute synchronous code before returning a PromiseInterface: getContextDataAsync() calls authRequest() directly, and publishAsync() calls encode() and potentially publish(). If these operations throw, the caller receives a synchronous exception rather than a rejected promise, breaking the contract for code expecting promise-based error handling.

Additionally, publish() lacks the authRequest() validation that getContextData() performs. The proposed fix addresses the promise wrapping but only adds authRequest() to publishAsync(), leaving the synchronous publish() method without API key validation—creating an inconsistency across the client interface.

Ensure both sync and async publish methods validate authentication, and wrap all sync operations in try/catch blocks that return rejected promises on failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Client/Client.php` around lines 71 - 95, getContextDataAsync and
publishAsync currently run synchronous code (authRequest, encode, or publish)
that can throw, causing sync exceptions instead of rejected promises, and
publish() lacks auth validation; wrap the synchronous parts of
getContextDataAsync (the authRequest call) and publishAsync (authRequest +
encode or the fallback publish call) in try/catch and return a rejected
PromiseInterface on exception, and add authRequest() at the start of the
synchronous publish(PublishEvent) method so both sync and async publish paths
validate auth; update references: getContextDataAsync, publishAsync, publish,
authRequest, encode, AsyncHttpClientInterface, and PromiseInterface to implement
these changes.


public function decode(string $jsonString): object {
return json_decode($jsonString, false, 16, JSON_THROW_ON_ERROR);
return json_decode($jsonString, false, 512, JSON_THROW_ON_ERROR);
}

public function encode(object $object): string {
Expand All @@ -71,4 +105,8 @@ public function encode(object $object): string {
public function close(): void {
$this->httpClient->close();
}

public function isAsync(): bool {
return $this->httpClient instanceof AsyncHttpClientInterface;
}
}
5 changes: 3 additions & 2 deletions src/Client/ClientConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use ABSmartly\SDK\Exception\InvalidArgumentException;

use function get_class;
use function str_repeat;
use function strlen;

Expand All @@ -23,6 +22,9 @@ public function __construct(
string $application,
string $environment
) {
if (($endpoint === '' && $apiKey !== '') || ($endpoint !== '' && $apiKey === '')) {
error_log('ABsmartly SDK Warning: ClientConfig created with empty endpoint or API key. This may cause runtime errors.');
}

$this->apiKey = $apiKey;
$this->application = $application;
Expand All @@ -40,7 +42,6 @@ public function __debugInfo(): array {
'application' => $this->application,
'endpoint' => $this->endpoint,
'environment' => $this->environment,
'eventLogger' => isset($this->eventLogger) ? get_class($this->eventLogger) : null,
];
}

Expand Down
12 changes: 12 additions & 0 deletions src/Client/ClientInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace ABSmartly\SDK\Client;

use ABSmartly\SDK\Context\ContextData;
use ABSmartly\SDK\PublishEvent;

interface ClientInterface {
public function getContextData(): ContextData;
public function publish(PublishEvent $publishEvent): void;
public function close(): void;
}
Loading