--- title: "player sample: PHP + Laravel" description: "Operator Contract v1.4.1 player module, PHP + Laravel. Generated from the contract." --- # player: PHP + Laravel The player lookup OneHazel calls for currency, country, status and active restrictions. Generated from Operator Contract v1.4.1. Key `php/laravel/player`; the `connect_samples` MCP tool returns this page for that key. ## What it implements | Operation | Purpose | Idempotency key | |---|---|---| | `POST /player/lookup` | Read a player's currency, country, status and active restrictions. | no | Every request is checked in this order: the `Authorization: Bearer` callback key, then `X-OneHazel-Signature` (`t=,v1=`, HMAC-SHA256 of `"." + raw body`, 300 s tolerance, constant-time compare) over the RAW body before it is parsed, then `X-OneHazel-Env`. `test` traffic goes to a separate test ledger and never touches the live one. Errors use the flat `{ code, message, retryable }` envelope; money is integer minor units. ## Run it ```bash php artisan install:api # Laravel 11+, only if routes/api.php does not exist yet # add ONEHAZEL_CALLBACK_KEY and ONEHAZEL_SIGNING_SECRET to .env, then: php artisan serve --port=3000 # base URL to register: https:///api/onehazel/player ``` Environment: `ONEHAZEL_CALLBACK_KEY` and `ONEHAZEL_SIGNING_SECRET` are the values `connect_register_endpoint` showed you once. During a secret rotation set `ONEHAZEL_SIGNING_SECRET_PREVIOUS` to the old secret for the overlap hour. `ONEHAZEL_TEST_PLAYER_ID` (default `test-player-1`) is the funded player on the test ledger. ## Code `app/OneHazel/common.php` ```php (string) getenv($k); $key = (string) $env('ONEHAZEL_CALLBACK_KEY'); $secret = (string) $env('ONEHAZEL_SIGNING_SECRET'); if ($secret === '' || ($requireCallbackKey && $key === '')) { // Name the variables, never print their values. throw new \RuntimeException($requireCallbackKey ? 'ONEHAZEL_CALLBACK_KEY and ONEHAZEL_SIGNING_SECRET must be set' : 'ONEHAZEL_SIGNING_SECRET must be set'); } $secrets = array_values(array_filter([$secret, (string) $env('ONEHAZEL_SIGNING_SECRET_PREVIOUS')])); return new self($contract, $key, $secrets); } /** @return array{0:int,1:array} [HTTP status, flat error envelope] */ public function errorReply(string $code, string $message): array { return [ $this->contract['errorStatus'][$code] ?? 500, ['code' => $code, 'message' => $message, 'retryable' => in_array($code, $this->contract['retryable'], true)], ]; } public function op(string $path): ?array { foreach ($this->contract['ops'] as $op) { if ($op['path'] === $path) { return $op; } } return null; } /** * `X-OneHazel-Signature: t=,v1=." + raw body)>`. * Runs over the RAW body, before it is parsed. Constant-time compare. */ public static function verifySignature(string $rawBody, ?string $header, array $secrets, int $toleranceSec, ?int $now = null): bool { if ($header === null || $header === '') { return false; } $t = null; $macs = []; foreach (explode(',', $header) as $part) { $kv = explode('=', trim($part), 2); if (count($kv) !== 2) { continue; } if ($kv[0] === 't' && ctype_digit($kv[1])) { $t = (int) $kv[1]; } if ($kv[0] === 'v1' && preg_match('/^[0-9a-f]{64}$/', $kv[1])) { $macs[] = $kv[1]; } } $now ??= time(); if ($t === null || abs($now - $t) > $toleranceSec || $macs === []) { return false; } foreach ($secrets as $secret) { $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($macs as $mac) { if (hash_equals($expected, $mac)) { return true; } } } return false; } /** Bearer, then signature over the raw body, then X-OneHazel-Env. Returns "test" or "live". */ public function authenticate(callable $header, string $rawBody): string { $auth = (string) $header('authorization'); if (!hash_equals('Bearer ' . $this->callbackKey, $auth)) { throw new OneHazelError('unauthorized', 'missing or wrong callback key'); } if (!self::verifySignature($rawBody, $header('x-onehazel-signature'), $this->secrets, (int) $this->contract['toleranceSec'])) { throw new OneHazelError('signature_invalid', 'bad or stale X-OneHazel-Signature'); } $env = $header('x-onehazel-env'); if ($env !== 'test' && $env !== 'live') { throw new OneHazelError('invalid_request', 'X-OneHazel-Env must be test or live'); } return $env; } /** * Webhook deliveries: `X-OneHazel-Signature: sha256=`, the * OneHazel webhook engine's format. No timestamp, no bearer, no X-OneHazel-Env, so the * signature is the only proof of origin and deduplicating on deliveryId is the only replay * defence. Runs over the RAW body, before it is parsed. Constant-time compare. */ public static function verifyWebhookSignature(string $rawBody, ?string $header, array $secrets): bool { if ($header === null || !preg_match('/^sha256=([0-9a-f]{64})$/', trim($header), $m)) { return false; } $ok = false; foreach ($secrets as $secret) { // No early exit: every secret is compared. $ok = hash_equals(hash_hmac('sha256', $rawBody, $secret), $m[1]) || $ok; } return $ok; } public function authenticateWebhook(callable $header, string $rawBody): void { if (!self::verifyWebhookSignature($rawBody, $header('x-onehazel-signature'), $this->secrets)) { throw new OneHazelError('signature_invalid', 'bad X-OneHazel-Signature'); } } public static function parseBody(string $rawBody): mixed { try { return json_decode($rawBody, false, 64, JSON_THROW_ON_ERROR); } catch (\JsonException) { throw new OneHazelError('invalid_request', 'body is not valid JSON'); } } /** Checks a decoded body against the contract's field list; returns it as an array. */ public static function checkFields(mixed $value, array $fields, bool $closed, string $where): array { if (!is_object($value)) { throw new OneHazelError('invalid_request', ($where === '' ? 'body' : $where) . ' must be a JSON object'); } $obj = get_object_vars($value); if ($closed) { $known = array_column($fields, 'name'); foreach (array_keys($obj) as $key) { if (!in_array($key, $known, true)) { throw new OneHazelError('invalid_request', "unknown field {$where}{$key}"); } } } foreach ($fields as $f) { $name = $where . $f['name']; $v = $obj[$f['name']] ?? null; if ($v === null) { if ($f['required']) { throw new OneHazelError('invalid_request', "{$name} is required"); } continue; } $ok = match ($f['type']) { 'integer' => is_int($v), 'string' => is_string($v), 'boolean' => is_bool($v), 'array' => is_array($v), 'object' => is_object($v), default => true, }; if (!$ok) { throw new OneHazelError('invalid_request', "{$name} must be {$f['type']}"); } if (is_int($v)) { if (isset($f['minimum']) && $v < $f['minimum']) { throw new OneHazelError('invalid_request', "{$name} is below {$f['minimum']}"); } if (isset($f['exclusiveMinimum']) && $v <= $f['exclusiveMinimum']) { throw new OneHazelError('invalid_request', "{$name} must be greater than {$f['exclusiveMinimum']}"); } } if (is_string($v)) { $len = mb_strlen($v); if (isset($f['minLength']) && $len < $f['minLength']) { throw new OneHazelError('invalid_request', "{$name} is too short"); } if (isset($f['maxLength']) && $len > $f['maxLength']) { throw new OneHazelError('invalid_request', "{$name} is too long"); } if (isset($f['pattern']) && !preg_match('/' . str_replace('/', '\/', $f['pattern']) . '/u', $v)) { throw new OneHazelError('invalid_request', "{$name} is malformed"); } if (isset($f['enum']) && !in_array($v, $f['enum'], true)) { throw new OneHazelError('invalid_request', "{$name} is not an allowed value"); } } if (isset($f['fields'])) { self::checkFields($v, $f['fields'], false, "{$name}."); } } return json_decode(json_encode($value), true); } /** Stable fingerprint of a body, so a replay with reordered keys still matches. */ public static function fingerprint(array $value): string { $canonical = function (mixed $v) use (&$canonical): mixed { if (!is_array($v)) { return $v; } if (!array_is_list($v)) { ksort($v); } return array_map($canonical, $v); }; return hash('sha256', json_encode($canonical($value))); } } /** * State that survives between PHP requests: one JSON file, locked for the whole * request. Fine for a sample; use your database in production. */ final class OneHazelFileStore { public function __construct(private string $path) { } public static function fromEnv(string $name, ?callable $env = null): self { $env ??= static fn (string $k): string => (string) getenv($k); $path = (string) $env('ONEHAZEL_STORE_PATH'); return new self($path !== '' ? $path : sys_get_temp_dir() . "/onehazel-{$name}-store.json"); } /** Runs $fn(&$state) under an exclusive lock and saves whatever it leaves in $state. */ public function transaction(callable $fn): mixed { $fh = fopen($this->path, 'c+'); if ($fh === false || !flock($fh, LOCK_EX)) { throw new OneHazelError('unavailable', 'store is unavailable'); } try { $raw = stream_get_contents($fh); $state = $raw ? json_decode($raw, true) : null; $result = $fn($state); ftruncate($fh, 0); rewind($fh); fwrite($fh, json_encode($state)); return $result; } finally { flock($fh, LOCK_UN); fclose($fh); } } } ``` `app/OneHazel/player.php` ```php > $directories env => player id => facts */ public function __construct(private OneHazelProtocol $protocol, private array $directories) { } public static function contract(): array { return json_decode(self::CONTRACT_JSON, true); } /** @param callable|null $env reads a setting by name; defaults to getenv() */ public static function fromEnv(?callable $env = null): self { $env ??= static fn (string $k): string => (string) getenv($k); $currency = $env('ONEHAZEL_TEST_PLAYER_CURRENCY') ?: 'EUR'; $testId = $env('ONEHAZEL_TEST_PLAYER_ID') ?: 'test-player-1'; $restrictedId = $env('ONEHAZEL_RESTRICTED_TEST_PLAYER_ID') ?: 'test-player-restricted'; $test = [ $testId => ['player_external_id' => $testId, 'currency' => $currency, 'country' => 'MT', 'status' => 'active', 'restrictions' => []], $restrictedId => [ 'player_external_id' => $restrictedId, 'currency' => $currency, 'country' => 'MT', 'status' => 'active', 'restrictions' => [['kind' => 'deposit_limit', 'detail' => 'Test restriction: 100.00 per day']], ], ]; // Replace `live` with your player database. return new self(OneHazelProtocol::fromEnv(self::contract(), $env), ['test' => $test, 'live' => []]); } /** @return array{0:int,1:array} [HTTP status, JSON body] */ public function handle(string $path, callable $header, string $rawBody): array { try { $op = $this->protocol->op($path); if ($op === null) { return [404, ['code' => 'invalid_request', 'message' => 'unknown operation', 'retryable' => false]]; } $env = $this->protocol->authenticate($header, $rawBody); $body = OneHazelProtocol::checkFields(OneHazelProtocol::parseBody($rawBody), $op['fields'], $op['closed'], ''); $player = $this->directories[$env][$body['player_external_id']] ?? null; // Unknown is a 404, never a 200 with empty fields. if ($player === null) { throw new OneHazelError('player_unknown', 'unknown player'); } return [200, $player]; } catch (OneHazelError $e) { return $this->protocol->errorReply($e->errorCode, $e->getMessage()); } catch (\Throwable $e) { error_log('player handler failed: ' . get_class($e)); return $this->protocol->errorReply('internal_error', 'internal error'); } } } ``` `app/Http/Controllers/OneHazelPlayerController.php` ```php (string) env($key, '')); // getContent() is the RAW body: the signature is computed over the exact bytes OneHazel sent. [$status, $body] = $module->handle('/' . $operation, fn (string $name): ?string => $request->header($name), $request->getContent()); return response()->json($body, $status, [], JSON_UNESCAPED_SLASHES); } } ``` `routes/api.php (add)` ```php /api/onehazel/player as this module's base URL. Route::post('onehazel/player/{operation}', OneHazelPlayerController::class)->where('operation', '.*'); ``` ## Before you go live - Replace the in-memory (or file) stores with your database. The idempotency store must survive restarts. - Point the `live` ledger at your real wallet; keep `test` traffic on the test ledger. - Run conformance (`connect_run_conformance`) until every scenario passes.