Skip to content
670 changes: 535 additions & 135 deletions README.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/Assignment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

106 changes: 93 additions & 13 deletions src/Context/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class Context {
private int $pendingCount = 0;
private bool $closed = false;
private bool $ready;
private int $attrsSeq = 0;

public function isReady(): bool {
return $this->ready;
Expand All @@ -73,6 +74,10 @@ public function isClosed(): bool {
return $this->closed;
}

public function pending(): int {
return $this->pendingCount;
}

public static function getTime(): int {
return (int) (microtime(true) * 1000);
}
Expand Down Expand Up @@ -194,6 +199,46 @@ public function getExperiments(): array {
return $return;
}

public function customFieldValue(string $experimentName, string $fieldName) {
$experiment = $this->getExperiment($experimentName);
if ($experiment === null || !isset($experiment->data->customFieldValues)) {
return null;
}

Comment on lines +202 to +207
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

customFieldValue must guard against a not-ready or finalised context.

Every other public data-access method calls checkReady() (which in turn calls checkNotClosed()). Without this guard, customFieldValue silently succeeds on a finalised context, diverging from the lifecycle contract enforced everywhere else.

🛡️ Proposed fix
 public function customFieldValue(string $experimentName, string $fieldName) {
+    $this->checkReady();
     $experiment = $this->getExperiment($experimentName);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 202 - 207, The customFieldValue method
currently skips lifecycle checks; add a call to checkReady() at the start of
customFieldValue(string $experimentName, string $fieldName) so it enforces the
same not-ready/not-closed guard as other public data-accessors (checkReady()
itself calls checkNotClosed()). Place the checkReady() invocation before
getExperiment(...) to ensure the method throws when the Context is not ready or
already finalised, matching the class's lifecycle contract.

$customFieldValues = $experiment->data->customFieldValues;
if (is_string($customFieldValues)) {
$customFieldValues = json_decode($customFieldValues, true);
}
Comment on lines +209 to +211
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

json_decode without JSON_THROW_ON_ERROR silently discards malformed JSON.

If customFieldValues is an invalid JSON string, json_decode returns null; is_object(null) is false, isset(null[$fieldName]) is false, and the method returns null — masking the parse error entirely. Experiment::__construct already uses JSON_THROW_ON_ERROR for consistency.

🐛 Proposed fix (apply same pattern on line 225 too)
-    $customFieldValues = json_decode($customFieldValues, true);
+    $customFieldValues = json_decode($customFieldValues, true, 512, JSON_THROW_ON_ERROR);
-    return json_decode($value, true);
+    return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Context/Context.php` around lines 209 - 211, The json_decode call that
converts $customFieldValues should use JSON_THROW_ON_ERROR (and appropriate
flags) so malformed JSON doesn't silently become null; update the decode in the
Context class (the $customFieldValues conversion) to call json_decode(..., true,
512, JSON_THROW_ON_ERROR) and wrap it in a try/catch to surface or rethrow the
JsonException (matching the pattern used in Experiment::__construct); apply the
same change to the other json_decode occurrence around the later check (the
similar call near line 225) so both spots consistently throw on parse errors.


if (is_object($customFieldValues)) {
$customFieldValues = get_object_vars($customFieldValues);
}

if (!isset($customFieldValues[$fieldName])) {
return null;
}

$value = $customFieldValues[$fieldName];
$type = $customFieldValues[$fieldName . '_type'] ?? null;

if ($type === 'json' && is_string($value)) {
return json_decode($value, true);
}

if ($type === 'number' && is_string($value)) {
if (strpos($value, '.') !== false) {
return (float) $value;
}
return (int) $value;
}

if (str_starts_with($type ?? '', 'boolean') && is_string($value)) {
return $value === 'true' || $value === '1';
}

return $value;
}

private function experimentMatches(Experiment $experiment, Assignment $assignment): bool {
return $experiment->id === $assignment->id &&
$experiment->unitType === $assignment->unitType &&
Expand All @@ -202,12 +247,30 @@ private function experimentMatches(Experiment $experiment, Assignment $assignmen
$experiment->trafficSplit === $assignment->trafficSplit;
}

private function audienceMatches(Experiment $experiment, Assignment $assignment): bool {
if (!empty($experiment->audience) && !empty((array) $experiment->audience)) {
if ($this->attrsSeq > ($assignment->attrsSeq ?? 0)) {
$attrs = $this->getAttributes();
$result = $this->audienceMatcher->evaluate($experiment->audience, $attrs);
$newAudienceMismatch = !$result;

if ($newAudienceMismatch !== $assignment->audienceMismatch) {
return false;
}

$assignment->attrsSeq = $this->attrsSeq;
}
}
return true;
}
Comment on lines +250 to +265
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

audienceMatches silently treats a null result from evaluate() as a mismatch.

AudienceMatcher::evaluate() returns ?boolnull signals "no filter defined". Because !null === true in PHP, $newAudienceMismatch is set to true when there is no filter. This is consistent with the initial-assignment path at line 313, so the cache-invalidation comparison remains coherent. However, adding a brief inline comment here would make the contract explicit for future readers.

Additionally, line 261 mutates $assignment->attrsSeq as a side effect of what looks like a pure boolean query. The mutation is intentional for cache coherence but is a mild code smell — consider extracting it into the call site in getAssignment to keep this method free of side effects.

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

In `@src/Context/Context.php` around lines 250 - 265, audienceMatches currently
treats a null from AudienceMatcher::evaluate() as a mismatch due to using
!$result and also mutates $assignment->attrsSeq inside what appears to be a
boolean check; add a brief inline comment in audienceMatches by the evaluate()
call clarifying that evaluate() returns ?bool and null is intentionally treated
as a mismatch here (i.e. "null => no filter => treat as mismatch for cache
invalidation"), and remove the side-effect by moving the assignment of
$assignment->attrsSeq = $this->attrsSeq out of audienceMatches into the
getAssignment call site (call audienceMatches to decide match, then update
$assignment->attrsSeq in getAssignment when appropriate) so audienceMatches
remains a pure predicate while preserving cache-coherence via getAssignment.


private function getAssignment(string $experimentName): Assignment {
$experiment = $this->getExperiment($experimentName);

if (isset($this->assignmentCache[$experimentName])) {
$assignment = $this->assignmentCache[$experimentName];
if ($override = $this->overrides[$experimentName] ?? false) {
if (array_key_exists($experimentName, $this->overrides)) {
$override = $this->overrides[$experimentName];
if ($assignment->overridden && $assignment->variant === $override) {
// override up-to-date
return $assignment;
Expand All @@ -219,7 +282,7 @@ private function getAssignment(string $experimentName): Assignment {
return $assignment;
}
} else if (!isset($this->cassignments[$experimentName]) || $this->cassignments[$experimentName] === $assignment->variant) {
if ($this->experimentMatches($experiment->data, $assignment)) {
if ($this->experimentMatches($experiment->data, $assignment) && $this->audienceMatches($experiment->data, $assignment)) {
// assignment up-to-date
return $assignment;
}
Expand Down Expand Up @@ -250,17 +313,17 @@ private function getAssignment(string $experimentName): Assignment {
$assignment->audienceMismatch = !$result;
}

if (isset($experiment->data->audienceStrict) && !empty($assignment->audienceMismatch)) {
if (!empty($experiment->data->audienceStrict) && !empty($assignment->audienceMismatch)) {
$assignment->variant = 0;
}
else if (empty($experiment->data->fullOnVariant) && $uid = $this->units[$experiment->data->unitType] ?? null) {
//$unitHash = $this->getUnitHash($unitType, $uid);
$assigner = $this->getVariantAssigner($unitType, $uid);

$eligible = $assigner->assign(
$eligible = $assigner->assign(
$experiment->data->trafficSplit,
$experiment->data->seedHi,
$experiment->data->seedLo
$experiment->data->trafficSeedHi,
$experiment->data->trafficSeedLo
);
if ($eligible === 1) {
$custom = $this->cassignments[$experimentName] ?? null;
Expand Down Expand Up @@ -296,10 +359,12 @@ private function getAssignment(string $experimentName): Assignment {
$assignment->fullOnVariant = $experiment->data->fullOnVariant;
}

if (($experiment !== null) && ($assignment->variant < count($experiment->data->variants))) {
if (($experiment !== null) && $assignment->variant >= 0 && ($assignment->variant < count($experiment->data->variants))) {
$assignment->variables = $experiment->variables[$assignment->variant];
}

$assignment->attrsSeq = $this->attrsSeq;

return $assignment;
}

Expand All @@ -311,11 +376,17 @@ public function getVariableValue(string $key, $defaultValue = null) {
return $defaultValue;
}

if (empty($assignment->exposed)) {
$this->queueExposure($assignment);
if ($assignment->variables !== null) {
if (empty($assignment->exposed)) {
$this->queueExposure($assignment);
}

if (isset($assignment->variables->{$key}) && ($assignment->assigned || $assignment->overridden)) {
return $assignment->variables->{$key};
}
}

return $assignment->variables->{$key} ?? $defaultValue;
return $defaultValue;
}

public function getVariableKeys(): array {
Expand All @@ -327,12 +398,13 @@ public function getVariableKeys(): array {
return $return;
}

public function setAttribute(string $name, string $value): Context {
public function setAttribute(string $name, $value): Context {
$this->attributes[] = (object) [
'name' => $name,
'value' => $value,
'setAt' => self::getTime(),
];
++$this->attrsSeq;

return $this;
}
Expand Down Expand Up @@ -457,6 +529,14 @@ public function setUnits(array $units): Context {
return $this;
}

public function getUnit(string $unitType) {
return $this->units[$unitType] ?? null;
}

public function getUnits(): array {
return $this->units;
}

public function setOverrides(array $overrides): Context {
// See note in ContextConfig::setUnits
foreach ($overrides as $experimentName => $variant) {
Expand Down Expand Up @@ -520,7 +600,7 @@ public function getPendingCount(): int {

private function checkNotClosed(): void {
if ($this->isClosed()) {
throw new LogicException('ABSmartly Context is closed');
throw new LogicException('ABSmartly Context is finalized');
}
}

Expand Down Expand Up @@ -595,7 +675,7 @@ public function close(): void {
return;
}

$this->logEvent(ContextEventLoggerEvent::Close, null);
$this->logEvent(ContextEventLoggerEvent::Finalize, null);
$this->closed = true;
$this->sdk->close();
}
Expand Down
1 change: 1 addition & 0 deletions src/Context/ContextEventLoggerEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ContextEventLoggerEvent {
public const Exposure = 'Exposure';
public const Goal = 'Goal';
public const Close = 'Close';
public const Finalize = 'Finalize';
Comment on lines 15 to +16
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:

rg -n --type=php 'ContextEventLoggerEvent::Close\b' -A2 -B2

Repository: absmartly/php-sdk

Length of output: 43


🏁 Script executed:

cat -n src/Context/ContextEventLoggerEvent.php

Repository: absmartly/php-sdk

Length of output: 902


🏁 Script executed:

rg -n --type=php 'ContextEventLoggerEvent::Finalize\b' -A2 -B2

Repository: absmartly/php-sdk

Length of output: 3030


Add deprecation docblock to Close constant or remove it.

The Close constant is not used anywhere in the codebase; Context.php now emits Finalize instead. To avoid confusing SDK consumers, add a @deprecated docblock directing them to Finalize, or remove the constant entirely if backwards compatibility is not a concern.

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

In `@src/Context/ContextEventLoggerEvent.php` around lines 15 - 16, The Close
constant in ContextEventLoggerEvent is now unused and should either be removed
or marked deprecated; update the Close constant by adding a PHP docblock with
`@deprecated` pointing consumers to use Finalize instead (e.g., above the Close
constant add "@deprecated Use ContextEventLoggerEvent::Finalize"), or if you
accept breaking changes, remove the public const Close declaration entirely and
ensure no references remain; keep the Finalize constant unchanged.


public function __construct(string $event, ?object $data) {
$this->event = $event;
Expand Down
1 change: 1 addition & 0 deletions src/Experiment.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Experiment {
public bool $audienceStrict;
public array $applications;
public array $variants;
public ?object $customFieldValues = null;

public function __construct(object $data) {
if (!empty($data->audience)) {
Expand Down
2 changes: 1 addition & 1 deletion src/Http/HTTPClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class HTTPClient {
public int $retries = 5;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

$retries is public but has no effect — either implement or remove it.

No retry logic exists anywhere in the class; the property is dead API surface. Callers who configure it will silently get no retries, contrary to expectation.

♻️ Option A — remove the property until retry logic is implemented
-    public int $retries = 5;
     public int $timeout = 3000;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public int $retries = 5;
public int $timeout = 3000;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Http/HTTPClient.php` at line 45, The public property $retries on class
HTTPClient is dead API surface—remove it (delete the property declaration
"public int $retries = 5;") and any related references or docblocks in the
HTTPClient class so callers don't get a misleading configurable field;
alternatively if you prefer to keep retries, implement retry logic in the
request/sending path (e.g., in sendRequest or request methods) that respects a
configured retry count and backoff, but do not leave the public $retries field
declared without behavior.

public int $timeout = 3000;
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

Only connection timeout is set — total transfer timeout is missing.

CURLOPT_CONNECTTIMEOUT_MS caps only the TCP handshake phase. There is no CURLOPT_TIMEOUT_MS to bound the full transfer (DNS + connect + send + receive), so a server that accepts the connection but sends a slow or never-ending response will stall the calling process indefinitely. The public $timeout property implies full-request coverage, which is misleading.

🛡️ Proposed fix — add total transfer timeout
 use const CURLOPT_CONNECTTIMEOUT_MS;
+use const CURLOPT_TIMEOUT_MS;
             CURLOPT_CONNECTTIMEOUT_MS => $this->timeout,
+            CURLOPT_TIMEOUT_MS => $this->timeout,

Also applies to: 124-134

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

In `@src/Http/HTTPClient.php` at line 46, The class currently exposes public int
$timeout = 3000 but only applies it to CURLOPT_CONNECTTIMEOUT_MS (TCP handshake)
— add a total-transfer timeout by setting CURLOPT_TIMEOUT_MS using the same (or
a new) property; specifically, in the code path where CURLOPT_CONNECTTIMEOUT_MS
is configured (and in the other block referenced around lines 124-134) add
CURLOPT_TIMEOUT_MS => $this->timeout (or introduce separate $connectTimeout and
$transferTimeout properties and set CURLOPT_CONNECTTIMEOUT_MS =>
$this->connectTimeout and CURLOPT_TIMEOUT_MS => $this->transferTimeout), and
update the property name/comments to clearly state the unit is milliseconds and
that it bounds the full request (DNS+connect+send+receive).


private function setupRequest(string $url, array $query = [], array $headers = [], string $type = 'GET', string $data = null): void {
private function setupRequest(string $url, array $query = [], array $headers = [], string $type = 'GET', ?string $data = null): void {
$this->curlInit();
$flatHeaders = [];
foreach ($headers as $header => $value) {
Expand Down
21 changes: 12 additions & 9 deletions src/JsonExpression/Operator/InOperator.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,30 @@

class InOperator extends BinaryOperator {

public function binary(Evaluator $evaluator, $haystack, $needle): ?bool {
if ($needle === null) {
public function binary(Evaluator $evaluator, $lhs, $rhs): ?bool {
if ($lhs === null) {
return null;
}

if (is_array($haystack)) {
return in_array($needle, $haystack, true);
if (is_array($rhs)) {
return in_array($lhs, $rhs, false);
}

if (is_string($haystack)) {
if (is_string($rhs)) {
if (!is_string($lhs)) {
return null;
}
//@codeCoverageIgnoreStart
// due to version-dependent code
if (function_exists('str_contains')) {
return str_contains($haystack, $needle); // Allows empty strings
return str_contains($rhs, $lhs);
}
return strpos($haystack, $needle) !== false;
return strpos($rhs, $lhs) !== false;
// @codeCoverageIgnoreEnd
}

if (is_object($haystack)) {
return property_exists($haystack, (string) $needle); // Not using isset() to account for possible null values.
if (is_object($rhs)) {
return property_exists($rhs, (string) $lhs);
}

return null;
Expand Down
12 changes: 3 additions & 9 deletions src/JsonExpression/Operator/MatchOperator.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,9 @@ public function binary(Evaluator $evaluator, $text, $pattern): ?bool {
}

private function runRegexBounded(string $text, string $pattern): ?bool {
/*
* If the user-provided $pattern has forward slash delimiters, accept them. Any other patterns will
* automatically get forward slashes as delimiters.
*
* This is not ideal, because unlike JS, regexps are strings, and working with user-provided patterns is
* prone to either security issues (too eager regexps, or simply Regexp errors), or having to enforce delimiters
* at source.
*/
$matches = preg_match('/'. trim($pattern, '/') . '/', $text);
$pattern = trim($pattern, '/');
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

trim($pattern, '/') discards PCRE flags from slash-delimited patterns.

trim('/foo/i', '/') yields foo/i (only the leading / is stripped because the last character is i, not /). The resulting regex ~foo/i~ matches the four-character literal foo/i rather than applying the i (case-insensitive) flag. If callers can supply patterns in the conventional PHP/PCRE form /pattern/flags, the flags must be split off before re-wrapping with the new delimiter.

🛡️ Proposed fix — separate pattern body from flags before re-wrapping
 private function runRegexBounded(string $text, string $pattern): ?bool {
-    $pattern = trim($pattern, '/');
-
-    $matches = `@preg_match`('~'. $pattern . '~', $text);
+    // Strip a single leading/trailing slash delimiter and capture any trailing flags.
+    $flags = '';
+    if (preg_match('/^\\/(.+)\\/([imsuxADSUXJ]*)$/', $pattern, $parts) === 1) {
+        $pattern = $parts[1];
+        $flags   = $parts[2];
+    } else {
+        $pattern = trim($pattern, '/');
+    }
+
+    $safePattern = str_replace('~', '\~', $pattern);
+    $matches = `@preg_match`('~' . $safePattern . '~' . $flags, $text);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/JsonExpression/Operator/MatchOperator.php` at line 28, The current
trim($pattern, '/') call can leave trailing PCRE flags attached to the body
(e.g. '/foo/i' -> 'foo/i'), so update the handling in MatchOperator to detect
slash-delimited patterns and split the pattern body and flags before
re-wrapping: if $pattern starts with '/' find the last '/' position, take the
substring between first and last slash as the body and the substring after the
last slash as flags, escape any new delimiter characters in the body, then build
the final regex using the chosen delimiter (e.g. '~') and append the flags;
apply this logic where $pattern is processed in the MatchOperator class to
ensure flags are preserved correctly.


$matches = @preg_match('~'. $pattern . '~', $text);
Comment on lines +28 to +30
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Patterns containing ~ will silently fail to evaluate correctly.

Using ~ as the PCRE delimiter while concatenating the raw user-supplied pattern string means any pattern that itself contains ~ (e.g. foo~bar) produces a malformed regex string like ~foo~bar~, which PCRE interprets as delimiter ~, pattern foo, flags bar~ — an invalid flag sequence. The @-suppression + preg_last_error() guard correctly returns null rather than crashing, but valid patterns with ~ are permanently broken. Consider escaping ~ in the pattern or choosing a delimiter that is less likely to collide (e.g. using preg_quote with the chosen delimiter, or selecting a non-printable delimiter).

🛡️ Proposed fix — escape the chosen delimiter before wrapping
 private function runRegexBounded(string $text, string $pattern): ?bool {
-    $pattern = trim($pattern, '/');
-
-    $matches = `@preg_match`('~'. $pattern . '~', $text);
+    $pattern = trim($pattern, '/');
+    // Escape the tilde delimiter so user-supplied patterns containing `~`
+    // do not break the PCRE boundary.
+    $safePattern = str_replace('~', '\~', $pattern);
+
+    $matches = `@preg_match`('~' . $safePattern . '~', $text);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/JsonExpression/Operator/MatchOperator.php` around lines 28 - 30, The
current MatchOperator implementation trims slashes then builds a regex using '~'
as the delimiter and calls preg_match('@preg_match(\'~'. $pattern . '~\',
$text)@'), which breaks when the user pattern contains '~'; change this to
escape occurrences of the chosen delimiter inside $pattern (or call
preg_quote($pattern, '~')) before concatenation so embedded '~' do not produce
malformed delimiters — update the code that sets $pattern (and the preg_match
invocation in MatchOperator.php) to use the escaped pattern (or a
preg_quote-based variant) when wrapping with the '~' delimiters.


if (preg_last_error() !== PREG_NO_ERROR) {
return null;
Expand Down
42 changes: 42 additions & 0 deletions tests/AudienceMatcherTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace ABSmartly\SDK\Tests;

use ABSmartly\SDK\AudienceMatcher;
use PHPUnit\Framework\TestCase;
use stdClass;

class AudienceMatcherTest extends TestCase {
private AudienceMatcher $matcher;

protected function setUp(): void {
$this->matcher = new AudienceMatcher();
}

public function testShouldReturnNullOnEmptyAudience(): void {
$audience = new stdClass();
self::assertNull($this->matcher->evaluate($audience, []));
}

public function testShouldReturnNullIfFilterNotObjectOrArray(): void {
$audience = (object) ['filter' => null];
self::assertNull($this->matcher->evaluate($audience, []));

$audience2 = new stdClass();
self::assertNull($this->matcher->evaluate($audience2, []));
}

public function testShouldReturnBoolean(): void {
$audience = (object) [
'filter' => [
(object) ['gte' => [
(object) ['var' => ['path' => 'age']],
(object) ['value' => 20],
]],
],
];

self::assertTrue($this->matcher->evaluate($audience, ['age' => 25]));
self::assertFalse($this->matcher->evaluate($audience, ['age' => 15]));
}
}
Loading