Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 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
0978673
fix(jsonexpr): restore haystack-first IN operator (CONTAINS semantics)
joalves Jun 15, 2026
622be80
fix: type Experiment.customFieldValues as ?array
joalves Jun 16, 2026
72279c2
fix(jsonexpr): skip binary operators on null operands
joalves Jun 16, 2026
528e9b1
fix: read customFieldValues as canonical array of {name,type,value}
joalves Jun 16, 2026
a0a41a7
test: exclude live-network integration suite from default phpunit run
joalves Jun 24, 2026
63c76ca
test: add hermetic local-HTTP integration test for real fetch/publish
joalves Jun 25, 2026
7cbb092
fix: restore pending events and propagate error on publish failure
joalves Jul 14, 2026
7a191fe
fix: address review feedback (react deps, async error handling, publi…
joalves Oct 20, 2026
7542186
fix: allow react/async ^3 for PHP 7.4/8.0 and drop non-existent promi…
joalves Oct 20, 2026
1a81a83
fix: keep exposures publishable after failed refresh, PHP<8.1 murmur …
joalves Oct 20, 2026
fbee828
fix: SDK::createWithDefaults returns SDK; make async client tests ass…
joalves Oct 20, 2026
c4f1972
fix: select AsyncContextDataProvider by default when the HTTP client …
joalves Oct 20, 2026
d136bd1
docs: remove unsupported retries option from async HTTP client setup
joalves Oct 20, 2026
9c37e4d
feat: retry transient failures in ReactHttpClient (also fix missing H…
joalves Oct 20, 2026
bc7bfc4
fix: make ReactHttpClient retries cancellable
joalves Oct 20, 2026
5606cbc
feat!: JS SDK behavioral parity (maintainer decisions D1-D8)
joalves Oct 23, 2026
b83efef
fix: keep numeric-key JSON objects equal in deep comparison
joalves Oct 23, 2026
ab65e08
fix: keep empty lists distinct from objects in deep comparison
joalves Oct 23, 2026
634f8bd
ci: trigger CI after re-enabling the workflow
joalves Oct 27, 2026
2086356
test: start local integration server portably on Windows
joalves Oct 27, 2026
b5dcab9
fix: align units, goal validation, failed loads and failed finalize w…
joalves Oct 27, 2026
b2d1b9a
fix: hash numeric units exactly like JavaScript toFixed(0); treat equ…
joalves Oct 27, 2026
83b005f
fix: distinct integer units always conflict, even beyond float precision
joalves Oct 27, 2026
9abb2dd
fix: format large numeric units independently of serialize_precision
joalves Oct 27, 2026
0c368c2
Address historical review cleanup
joalves Oct 27, 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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Changelog

## 2.0.0 (unreleased)

### BREAKING

- **Units keep their type.** `setUnit`/`setUnits` (Context and ContextConfig) accept strings or numbers and
`getUnit`/`getUnits` return the value as given (old: numbers were cast to strings). A float unit is hashed as
its value rounded to an integer, like the JavaScript SDK. String UIDs are trimmed.
Unit error messages now match the other SDKs: `Unit '<type>' UID already set.`,
`Unit '<type>' UID must not be blank.`, `Unit '<type>' must be a string or a number.`
- **`track()` validates goal properties.** Anything other than null, a plain object or an associative array
raises `Goal '<name>' properties must be of type object.` (old: PHP `TypeError` or silently accepted).
- **A failed context load no longer rejects.** `createContextAsync()`/`createContextPending()` resolve with a
context that is ready and failed, has empty data, stores `readyError()` and logs an `Error` event.
- **`close()` rethrows a failed final publish.** The events stay queued and the context stays open so `close()`
can be retried (old: the error was swallowed and the context closed).
- New `ABSmartly\SDK\Utils` helpers (`hashUnit`, `base64UrlNoPadding`, `utf8Bytes`, `isObject`, `isNumeric`,
`isPromise`).

- **Context reads throw before ready and after finalize** (parity with the JavaScript SDK `_checkReady`).
The following methods now throw `ABSmartly\SDK\Exception\LogicException`:
`getTreatment`, `peekTreatment`, `getVariableValue`, `peekVariableValue`, `getVariableKeys`,
`getExperiments`, `getCustomFieldKeys`, `customFieldValue`, `getCustomFieldValueType`.
- Before ready (e.g. a context from `createContextPending()` whose data has not arrived):
message `ABsmartly Context is not yet ready.`
Old: `peekVariableValue`/`getVariableKeys`/`getExperiments` returned defaults or empty arrays.
- After `close()`/`finalize()`: message `ABsmartly Context is finalized.`
Old: `peekVariableValue` and `getVariableKeys` kept returning values.
`getExperiments()` is still allowed after finalize (as in JS).
- The not-ready message changed from `ABSmartly Context is not yet ready` to
`ABsmartly Context is not yet ready.` Code matching on the message text must be updated.
674 changes: 516 additions & 158 deletions README.md

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
"php-64bit": "^7.4 || ^8.0",
"lastguest/murmurhash": "~2.1.1",
"ext-curl": "*",
"ext-json": "*"
"ext-json": "*",
"react/http": "^1.9",
"react/async": "^3.0 || ^4.0",
"react/promise": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5.26"
Expand All @@ -27,5 +30,10 @@
"email": "ayesh@aye.sh"
}
],
"keywords": ["absmartly"]
"keywords": [
"absmartly"
],
"scripts": {
"test": "phpunit"
}
}
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
convertDeprecationsToExceptions="true"
failOnRisky="true"
failOnWarning="true"
defaultTestSuite="default"
verbose="true">
<testsuites>
<testsuite name="default">
Expand Down
174 changes: 174 additions & 0 deletions src/ABsmartly.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?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\reject;
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 {
return new ABsmartly(static::buildConfig($endpoint, $apiKey, $application, $environment, $retries, $timeout, $eventLogger));
}

/**
* @deprecated Use createSimple(); this legacy signature takes environment before application.
*
* @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 {
return new ABsmartly(static::buildConfig($endpoint, $apiKey, $application, $environment, $retries, $timeout, $eventLogger));
}

protected static function buildConfig(
string $endpoint,
string $apiKey,
string $application,
string $environment,
int $retries,
int $timeout,
?callable $eventLogger
): Config {
$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 $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) {
Comment thread
joalves marked this conversation as resolved.
Resolved
try {
return resolve($this->createContext($contextConfig));
} catch (\Throwable $e) {
return reject($e);
}
}

return $this->createContextPending($contextConfig)['promise'];
}

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) {
try {
$data = $this->provider->getContextData();
} catch (\Throwable $error) {
$context->setContextDataFailed($error);
return $context;
}
$context->setContextData($data);
return $context;
});
} else {
$promise = $this->provider->getContextDataAsync()
->then(
function($data) use ($context) {
$context->setContextData($data);
return $context;
},
function(\Throwable $error) use ($context) {
$context->setContextDataFailed($error);
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;
}

4 changes: 4 additions & 0 deletions src/AudienceMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public function evaluate(stdClass $audience, array $attributes): ?bool {
return null;
}

if (!is_array($audience->filter) && !is_object($audience->filter)) {
return null;
}

return $this->expression->evaluateBooleanExpr($audience->filter, (object) $attributes);
}
}
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;
}
Loading
Loading