slen/sdk — the PHP/Laravel SDK: runs flows and reads their outcome, keeps end customers and their credits, checks webhooks. An entry point over a config, PSR-18/17 through discovery, PSR-3 for the journal, a facade per module, typed DTOs, an error mapper, a chain of HTTP decorators.
php artisan boost:add-skill slen/sdk
Save this content to: AGENTS.md
# slen-php
## Purpose
`slen/sdk` — the PHP/Laravel SDK: runs flows and reads their outcome, keeps end customers and their credits, checks webhooks. An entry point over a config, PSR-18/17 through discovery, PSR-3 for the journal, a facade per module, typed answers, an error mapper, a chain of HTTP decorators.
This repository is the package itself. `contract/` and the JSON of `examples/help/` are copies of the contract the SDKs share (PHP, TypeScript, Python); the contract is kept, and the SDKs are held against the API's OpenAPI spec, in the SDK's source repository.
## Key Files
| File | Description |
|------|-------------|
| `composer.json` | `php: ^8.3`; runtime `psr/http-client`, `psr/http-factory`, `psr/http-message`, `psr/log`, `php-http/discovery` — no HTTP client. Guzzle, Symfony HttpClient + nyholm/psr7, illuminate/*: dev and suggest. `composer quality` = phpcs, php-cs-fixer, phpstan (level max), rector. `composer.lock` is not committed (a library) |
| `src/SlenSdk.php`, `src/SlenConfig.php` | `final readonly`; `SlenSdk::create(SlenConfig)` builds the HTTP client once; `withLogger()`, `operations()`, `executions()`, `customers()`, `webhooks()`, `health()`, `config()`; readonly settings, checked in the constructor, `with*()` copies |
| `src/{IdempotencyKeys,LogMessages}.php`, `src/{Exceptions,Data,Observability,Testing}/` | the keys; the journal's event names; the hierarchy; `Health`, `Page`; `SlenSpan` and the recorders; `FakeHttpClient`, `RecordingSpanRecorder` |
| `src/Shared/` | `@internal` only: `SdkFactory` (the chain, the modules), `Http/` (`Enums/Endpoint`, `Data/ApiRequestDTO`, `Data/ApiResponseDTO`; `Client/` — `HttpClientFactory`, `ApiClient`, the decorators, `RetryPolicy`; `Mappers/ErrorMapper`; `ApiCaller`, `CursorPager`), `Resources/BaseResource`, `Logging/Journal`, `Observability/SpanScope` |
| `src/Modules/{Operations,Executions,Customers,Webhooks}/` | facade, `Factories/`, `Tasks/`, `Resources/`, `Data/`, `Enums/`; `Executions/Actions/WaitForExecutionAction`; `Webhooks/Services/{SignatureVerifier,EventParser}` |
| `src/Laravel/` | `SlenServiceProvider` (auto-discovered), the facade `Slen` |
| `config/slen-sdk.php` | the Laravel keys and their variables |
| `bin/floor.php` | the package run on PHP 8.3 without PHPUnit: what CI's `php-floor` executes |
| `bin/export-help-slice.php` | writes `help-slice.json` (`--write`, `--check`); a tool of the repository, run with the dev dependencies |
| `tests/Unit/` | mirrors `src/`; `ContractTest`, `SourceHygieneTest`, `ReadmeTest`; `Support/` — answers, doubles, a bare Laravel container |
| `tests/Paths.php` | where the suite and the tools find `contract/` and `examples/`: one line each to change when they move |
| `tests/{HelpExamplesTest,HelpExampleFilesTest,HelpSliceTest,SourceFloorTest}.php` | the examples of `examples/help` run, and their `php.php` checked; the help slice; the syntax floor; outside `composer quality` |
| `contract/` | `calls.json`, `error-codes.json`, `unmapped-error-codes.json`, `span-attributes.json`, `webhook-vectors.json` — copies, never edited here; `SOURCE` names the commit of the canon they were copied from |
| `examples/help/<id>/` | `php.php` (this package's code of the example) beside the copied `example.json` and `expect.json` |
| `help-slice.json` | the help slice (schema 2), generated by `bin/export-help-slice.php` |
| `.github/` | `workflows/ci.yml` (no secrets), `dependabot.yml`, `AGENTS.md` |
## Layout
- **Modules: facade → resource.** A facade is the public calls: it builds the request (`Endpoint` plus values), sends it through `ApiCaller` and hands the answer to a resource, which turns the wire into a typed answer; a factory composes. A task only where a call has logic of its own: `MoveCreditsTask` (the credits), `ExecuteOperationTask` (the context, the key), `GetExecutionStatusTask` (the read timeout of a wait), `DeleteCustomerTask` (a 404 after no answer). A new call is an `Endpoint` and a facade method (and options, a type and a resource for a new answer). An Action only where a call is a loop (`WaitForExecutionAction`). Not Porto: no container, no UI layer — a modular client in the manner of Stripe's and openai-php's.
- **A call takes positionally only what names its subject** (operation and input, an id, a credit type, credits, a webhook's body and header), then one optional `…Options`; outputs are typed answers. A data class has no `fromArray()`: reading the wire is the resource's job.
- **An options class** is `final readonly`, public promoted properties with defaults, no checks (`ApiRequestDTO` checks before the wire); a secret is private with a getter and `__debugInfo()`. No `DTO` suffix — the house rule's exception: the names are the public surface shared with TypeScript.
- **A public type of an answer** — a class of the root's or a module's `Data` without `@internal`, a webhook event, `Observability\SlenSpan` — has no `DTO` suffix either: its name is the TypeScript type's (the response schema of the spec), one name for both packages and the help. An internal data class (`@internal`) ends with `DTO` (`PublicSurfaceTest`).
- **The public surface is a namespace** (`PublicSurfaceTest`): the root, `Exceptions`, `Data`, `Observability`, `Testing`, `Laravel`, a module's facade, `Data`, `Enums`, `Options`. `Shared` and a module's `Factories`, `Tasks`, `Resources`, `Actions`, `Services` are `@internal` only; an internal class may sit in `Data` or `Enums` (`CreditMovement`). No old name is aliased.
- **A facade is `readonly` and holds one factory**; `ApiCaller`, tasks, resources and `ErrorMapper` are `readonly` too (`BaseResource` — `abstract readonly`). Composition, not inheritance: a task takes what it sends through in its constructor — `ApiCaller`, or `ApiTransport` for `DeleteCustomerTask`, which reads a refusal itself.
- **Static only on the public surface** (`WorkerModeTest`, the house's one exception): `SlenSdk::create()`, `WebhooksFacade::standalone()` — named constructors; `IdempotencyKeys::generate()`, `forJob()`, `SlenException::type()` — pure functions; `Slen::getFacadeAccessor()` — Laravel's. No static property anywhere: a worker serves many requests.
- **The house style is held by tests**: no `else`/`elseif` and no `if (!` in `src/`, `config/`, `bin/` or an example (`SourceHygieneTest`) — an early return, `match`, `=== false`; booleans compared explicitly; closures, never `fn` (`DisallowArrowFunction`); one return type or `?T` (`HouseRulesTest`; `AttemptOutcomeDTO` carries an answer or its absence).
- **A task knows nothing of spans**: the facade opens the scope through its module's factory (`HouseRulesTest`: `new SpanScope` only in `Factories/`), and `ExecutionSpanMapper` turns a run, `ExecutionResult` or `ExecutionStatusView`, into its `SpanResultDTO` in one place.
- **At most three arguments and three dependencies** (`HouseRulesTest`): more travel together in a DTO (`ExecuteRequestDTO`, `CreditMovementDTO`, `ContextClaimDTO`, `WaitStateDTO`, `RejectionDTO`, `SpanSubjectDTO`); only the constructors of data classes (a `Data` namespace, `SlenSpan`), options, exceptions and `SlenConfig` take more. Time is one seam, `Shared/Timing/TimingInterface` (`SystemTiming`; `FakeTiming` in tests).
- **Tests go through the public surface on the doubles of the transport** (`FakeHttpClient`, `ScriptedApiClient`, `FakeTiming`): the house's mirror test per class and `createMock` do not fit a library. A class of its own logic has its own test: `MoveCreditsTaskTest`, `ExecuteOperationTaskTest`, `SignatureVerifierTest`, `EventParserTest`. Plain `TestCase`, no Laravel application booted.
- **Comments**: a public class and a public method carry one line, then tags; `@param` says more than its type only there, and only a range, a format or what `null`/`'0'` means; an `@internal` docblock is tags only; `//` only for a constraint the line itself must show. The reasons are here, as rule — reason (`SourceHygieneTest`).
## For AI Agents
- **`SlenConfig` checks itself** (`InvalidArgumentException`): an empty key, a timeout not above zero, negative retries, a webhook tolerance below 1. `baseUrl` is not checked; a blank webhook secret is none — as in TypeScript.
- **The API key and the webhook secret** are private with getters, `#[\SensitiveParameter]`, hidden by `__debugInfo()`: no dump, encoding or trace of a config carries them.
- **The address** is the constant `SlenConfig::DEFAULT_BASE_URL` (`https://api.slen.ai`, `servers[0]` of the spec); `baseUrl` only for tests and a local stack; the SDK adds `/api`. No README or example names it (`ReadmeTest`, `HelpExampleFilesTest`).
- **PSR-18 has no timeout**, so `HttpClientFactory` builds the clients it knows with the config's timeouts: Guzzle (`timeout`, `connect_timeout`, `http_errors: false`), else Symfony `Psr18Client` (`timeout`, `max_duration`). Any other client is discovered and keeps its own (`slen.config.timeout_not_applied`, `client`); a client in the config is used as it is.
- **A call's `timeout`** replaces the config's for each attempt: `GuzzleTransport` sends `timeout` (and PSR-18's `http_errors`/`allow_redirects: false`), `SymfonyTransport` uses `withOptions()` (`timeout`, `max_duration`; the pool is shared); `Psr18Transport` cannot, and the first attempt of a call that set `timeout` writes `slen.config.timeout_not_applied` (`method`, `route`, `client`).
- **`withOptions()` came with symfony/http-client 6.2**: an older `Psr18Client` gets `Psr18Transport` (a client the SDK builds keeps the config's timeouts). No `conflict` in `composer.json` — it would keep the SDK out of a Symfony 5.4 project that sends with Guzzle.
- **The chain** (outer → inner): `LoggingClientDecorator` → `RetryClientDecorator` → `AuthClientDecorator` → `ApiClient`. The journal sits outside the retry, so it writes how the call ended once; Auth sits inside, so no journal sees `Authorization`; it names the SDK `slen-sdk-php/<tag> php/<version>` (the version of a package is its tag).
- **A request is an `Endpoint` plus values** (`ApiRequestDTO`): the journal and every message name `POST /v1/customers/{externalUserId}/charge`, never a value — an external user id may be an email address.
- **Every path of the API is a case of `Endpoint`** (`Shared/Http/Enums`), its value the `operation` of `contract/calls.json` (`'PUT /v1/customers/{externalUserId}/accounts/{creditType}'`), not snake_case: the one table `EndpointTest` holds against the call table both ways, and no `/v1/` or `/health` literal lives outside it.
- **Refused before the wire** (`InvalidRequestException`, `invalid_request`; nothing sent, nothing written): a missing or blank path value, a blank idempotency key — never swapped for a generated one, that turns the caller's retry into a second run — a `timeout` not finite or not above zero, `retries` below zero, a body that is not JSON. An empty body goes as `{}`.
- **The end of the chain sends once**: `Accept: application/json`, `Content-Type` only with a body, `Idempotency-Key` when there is one; the query drops nulls and writes booleans as `true`/`false`.
- **No answer** is `TimeoutException` when the cause chain says so (Symfony `TimeoutExceptionInterface`, Guzzle 8's `ConnectTimeoutException`, `NetworkTimeoutException`, `ResponseTimeoutException`, Guzzle 7's cURL errno 28 read only where `getHandlerContext()` exists, "timed out"), else `ConnectionException`; no code, no status, the transport's exception as `previous`. Guzzle 8 has no `getHandlerContext()`: called unguarded it is an `\Error` that escapes every `catch (SlenException)`.
- **An unreadable 2xx is flagged, not thrown** (`ApiResponseDTO::$readable`), so the journal knows its attempt; `ApiCaller` throws `InvalidResponseException` (`invalid_response`, with status and request id), never repeated.
- **Retry** (`RetryPolicy`): no answer, 429 and any 503, with the same key on every attempt. 500, 502, 504 are never repeated — a provider may have been paid. A call's `retries` (`ApiRequestDTO::$retries`) replaces the config's; `0` is one attempt.
- **The pause**: `Retry-After` (seconds or an HTTP date) up to 30 s is waited out; past 30 s the answer goes back at once as its error; without it 200 ms × 2ⁿ ±25 %, at most 5 s after the jitter.
- **`ApiResponseDTO::$afterNoAnswer`** says an earlier attempt got no answer: `customers()->delete()` takes a `404 customer_not_found` after it as success.
- **Errors**: `ErrorMapper::BY_CODE` is `contract/error-codes.json`; `ErrorMapperTest` holds it both ways — every contract type to its class and parent, every class of `Exceptions/` to a contract type. A change starts in the contract the SDKs share; the copy here comes with this table in one commit.
- **A code outside the table falls back by status**; a 5xx without a code is `ProviderException` with a null code — the fallback never invents one, the README sends the caller to the code.
- **An exception carries** `errorCode`, `statusCode`, `details` (`error.details`, or `errors` of framework validation), `requestId` (`X-Request-Id`, else `error.request_id`), `retryAfterSeconds`; `type()` is the contract's name.
- **The request id of an answer**: the eight types of a top-level result (`Health`, `Page`, `CreditPriceList`, `Customer`, `CustomerAccount`, `ExecutionResult`, `ExecutionStatusView`, `ExecutionTrace`) end with `public ?string $requestId = null`, read the same way (`ApiResponseDTO::requestId()`); a resource takes it as the second argument of `map()`/`page()`/`list()`, the rows of a page and a nested account get `null`. `wait()` answers with the id of its last read; a webhook event has none.
- **The caller's own id**: `requestId` of the eight options that carry `timeout` and `retries` (not `WaitOptions`, `VerifyOptions`, `WebhooksOptions`); `ApiRequestDTO` refuses one not `^[A-Za-z0-9._:-]{8,128}$` before the wire (the API swaps any other for its own without a word), `ApiClient` sends it as `X-Request-Id` on every attempt, and a call that got no answer throws with it; an answer's id wins wherever there is one. `listAll()`/`entriesAll()` refuse it: one id names one request.
- **`ApiCaller`** has one method: `exchange()` hands back the 2xx `ApiResponseDTO` or throws the refusal; `ApiResponseDTO::data()` is the `data` of its envelope (`health()` reads the whole body). A movement's or a run's key is the caller's or `IdempotencyKeys::generate()`, chosen by `MoveCreditsTask` and `ExecuteOperationTask`.
- **Resources are strict**: a missing or mistyped field is `InvalidResponseException`, never a default — an answer of defaults hides a call that went through. Money and credits are decimal strings.
- **Idempotency keys**: `IdempotencyKeys::generate()` (UUID v4); `forJob($jobId, $operation)` = `job:{id}:{operation}`, one key for every attempt of a job.
- **A worker shares one SDK** (`WorkerModeTest`): 1 000 requests through `withLogger()` on `ConstantHttpClient` (it echoes the idempotency key as `X-Request-Id`) — every record in its own request's logger, under 64 KB kept after 100 to warm up; a copy leaves the shared SDK and the copies before it writing where they did; an SDK built in a clone of the container, as Octane's sandbox does, does not reach that clone — it keeps its clients and logger, and the application's logger may hold the base application, which lives as long as the worker.
- **`withLogger()` is a new `SlenSdk`** (`SdkFactory::modules()`): a new `Journal`, chain and facades over the same `HttpContext` (client, factories, pool) and span recorder; the original writes where it did. An SDK never changes once built: a worker shares one, a request's logger goes on a copy. Modules get `ApiTransport` (chain, `ErrorMapper`, timeout) and `Telemetry`.
- **The journal** is `SlenConfig::$logger` (silent unless given); `Journal` is `final readonly`, the one holder every link of one SDK shares. Names are `LogMessages`, the same names and fields as TypeScript's `LOG_EVENTS`; levels are PSR-3 (`warning` where TypeScript writes `warn`).
- **Written by the retry**: `slen.request` (debug, every attempt), `slen.retry` (warning: the attempt that failed, `delay_ms`, `reason` `no_answer` / `rate_limited` / `unavailable`, `status`), `slen.failed` of a call that got no answer at all (`attempts`).
- **Written by the logging decorator**: `slen.response` (debug: `status`, `duration_ms`, `request_id`), `slen.refused` (info, a 4xx), `slen.unauthorized` (error: 401; 403 under `insufficient_scope`, `invalid_api_key`, `not_a_member` or no code), `slen.failed` (error: 5xx, unreadable 2xx; `error_type`, `attempts`).
- **`error_type` is the contract's name of the type** (`Provider`, `InvalidResponse`): one word for every language.
- **Never written**: the key and `Authorization`, the webhook secret, the idempotency key, bodies, the server's `error.message`, path and query values. `SlenSdkJournalTest` plants values in each and searches every record. A logger that throws is swallowed.
- **A span is ten fields** (`SlenSpan`), attributes in snake_case, a null left out, `false` kept; none can hold a prompt or an answer. `SlenSpanTest` holds the class to `contract/span-attributes.json` and the names the calls write.
- **`SpanScope` writes once per call**: an answered run is `refused` when `rejected`, `error` when `failed` or `uncertain`, else `ok`; a thrown 402 or policy refusal is `refused`, anything else `error`. What a recorder throws is swallowed: telemetry never fails a run.
- **Spans are for runs only**: `operations.execute`, `operations.execute_async`, `executions.wait`; the name is the call's, never the answer's. `get()`, `trace()`, customers and webhooks write none.
- **The doubles are public and strict**: `FakeHttpClient` (PSR-18; `pushJson()`, `pushException()`, `requests()`, `lastRequest()`) refuses a request with nothing queued with `LogicException`; `RecordingSpanRecorder` (`spans()`, `last()`) the same. Consumers test with them.
- **`operations()->execute()` / `executeAsync()`** (`ExecuteOptions` in an `ExecuteRequestDTO`): `customer` is `context.user_id`, `creditType` `context.credit_type`; a blank one, or a `context` naming another (`42` and `'42'` are one), is refused before the wire — the task builds the request before the span opens. An empty input goes as `{}`, an empty context not at all.
- **A run is two types with the same fields**: `ExecutionResult` (plus `error`, `replayed`) and `ExecutionStatusView`; the wire's `operation` is `$operationCode`. Statuses are the enum `ExecutionStatus` (`isPending()`, `isFinished()`); `isRejected()` is a `reject` node's answer, not "did not complete".
- **The trace** keeps node types, node statuses and event types as strings: they grow with the runtime, and a strict enum would leave an old SDK unable to read a new trace. There is no `mode`: the test mode went, and the field left the trace with it.
- **The wait** (`WaitForExecutionAction`): `start()` builds the request once, before the facade opens the span; every read is sent with `retries: 0` — the wait is the loop that repeats it — and, where the transport applies it (`ApiTransport::$readTimeout`), a timeout of `min(config, max(5 s, time left))` — elsewhere a read asks for none and warns of nothing; the deadline is checked between reads.
- **Its pause**: `retry_after_seconds` of the answer, or the `Retry-After` of a failed read, else 1 s doubling to 30 s; ±25 %, at least 0.25 s, never past the deadline, where the run is read once more. Sleep, clock and random are `TimingInterface`; `RetryPolicy` pauses the retry through it too.
- **Its failures**: no answer, a 429 or a 5xx is `slen.wait.poll_failed` and `$onPollError`, and the wait goes on; anything else is thrown at once, the listener's own exception too. Time up: the last state read, or the last failure when none was read.
- **Customers**: `list()` always sends `view=customers` (an old backend answers the page of accounts without it; a newer one takes only `customers`, else `422`) and `limit`; `create()` and `unblock()` send `{}`, `block()` `{}` or `{reason}`; `updateAccount()` sends both settings. No span: moving credits is not a run.
- **A movement** (`topUp`, `charge`, `adjust` — one `MoveCreditsTask` over `CreditMovementDTO`) always sends `credit_type` (`default`), `note` only when given, the caller's key or a UUID v4, the same on every attempt. Refused before the wire: credits not `^-?\d+(\.\d{1,4})?$`, a top-up or charge not above zero, a correction of zero, a blank credit type.
- **A page** is one `final readonly` `Page` (`@template T`: `Page<Customer>`, `Page<CreditEntry>`; `data`, `nextCursor`), read off the whole answer; no `hasMore()` — a full last page carries a cursor to an empty one. `prices()` answers a `CreditPriceList` (`data`), the wire's `{ data }`. `CustomerStatus` and `CreditEntryType` are strict enums: a value of a later release is `InvalidResponseException`, as the spec draws them.
- **Every page** (`listAll()`, `entriesAll()`): the facade hands out the generator of `CursorPager`; nothing is sent before the first read. The first page is the options' `cursor`, then the `nextCursor` of the page before until one is null (at a boundary, one request of an empty page); `limit` is the size of a page, `timeout` and `retries` hold for each. Rows go one by one, the keys running on (`yield $row`, never `yield from`); a page whose `nextCursor` is the cursor it was asked with is `InvalidResponseException` after its rows. To resume, `list()`/`entries()` with a `nextCursor`.
- **Webhooks** (`webhooks()`; `WebhooksFacade::standalone(new WebhooksOptions(…))` for a receiver without a key): no or blank header `missing_header`; one `t` of digits and at least one `v1`, else `malformed_header`; `|now − t|` over the tolerance `outside_tolerance`; HMAC-SHA256 of `"{t}.{payload}"` against every `v1` with `hash_equals`, else `no_matching_signature`.
- **`VerifyOptions`** of a call: `secret`, else the configured one; `toleranceSeconds` (1 or more, else `InvalidArgumentException`); `now`, the moment of the check. A blank secret passed is `InvalidArgumentException` — never swapped for the configured one — and so is none at all. A refusal is `slen.webhook.rejected` (`reason`, `age_seconds`), never the body, header or secret.
- **Events**: the envelope needs `id`, `type`, `created_at`, `data`; the four typed events read exactly their schema (`WebhookEventsContractTest`, with the spec); any other type is `UnknownWebhookEvent`. Not an event: `InvalidResponseException` `webhook_payload_invalid`; a failed signature: `WebhookSignatureException` with `reason`.
- **Laravel**: `SlenServiceProvider` binds `SlenConfig`, `Observability\SpanRecorderInterface` and `SlenSdk` as singletons from `config/slen-sdk.php`. Every value goes through one reader, `ContainerConfigReader` (`@internal`, an instance over the container): blank is none; the timeouts are cast to `float` — an int cast makes 0.5 a zero, and zero is no timeout.
- **`config/slen-sdk.php` has no `base_url`**: the address is the SDK's constant, so the file an application publishes names none (`ConfigFileTest`). The provider still reads `slen-sdk.base_url` when a test or a local stack sets it; null or blank is the default address.
- **`log_channel`** is a channel of the application's log manager, else the application's `LoggerInterface`, else nowhere; `spans: log` is `Psr3SpanRecorder` on that logger at `span_level`, which it refuses when built if PSR-3 has no such level — a logger throws on one, better at wiring than after a run was paid for. Held by `tests/Unit/Laravel/` on `illuminate/container` without the framework.
- **The facade `Slen`** points at `SlenSdk` (`@method` for its modules and `health()`).
- **The contract test** (`tests/Unit/ContractTest.php`): its rows are the lines of `contract/calls.json`; every public call is a row or a `NOT_CALLS` entry (the two webhook calls). Answers are sampled from the spec — the fullest and the leanest of each 2xx — and read through the call; the fullest leaves no field of a typed answer null. A call of every page is a row read to its first row; another test reads it over two pages of the spec, the second with the cursor of the first.
- **The help examples** (`tests/HelpExamplesTest.php`) include each `examples/help/*/php.php`; `HelpExampleFilesTest` checks those files as the Node suite checks its own (regions, `NOT_SHOWN`, `RETIRED`, what a region leans on, PHP above the floor) — the checks across languages run where the contract is kept; `$offline($slen)` answers `SlenSdk::create($slen->config()->withHttpClient(<the double>))` — key, logger and recorder stay the example's. A Laravel example gets `SlenSdk` from the container, the facade and `app()`, on the same double. Rules: "Help examples" below.
- **The README shows the surface as the code has it** (`ReadmeTest`): every code block on the current calls and options, no address, keys `sl_live_…`, no `else` or `elseif` in a block — an early return, `match (true)` or independent `if`s.
- **What ships is `composer.json`, `src/`, `config/`, `README.md`, `LICENSE`, `CHANGELOG.md`**: `.gitattributes` export-ignores the rest (`tests/`, `bin/`, the tools' configs, this file). So `bin/` keeps rules of its own: the slice exporter's private statics and longer parameter lists stay, `HouseRulesTest` reads `src/` only; `bin/` still has no `else` and no `if (!` (`SourceHygieneTest`).
- **A release** is a line of `CHANGELOG.md` (Keep a Changelog) and a tag; `composer validate --strict` is clean; `conflict` keeps `illuminate/support` at 11 or newer (the README's Compatibility, held by `ReadmeTest`).
- **The help slice lists every class of `src/`**: after a change there, `php bin/export-help-slice.php --write` and commit `help-slice.json` with it (`HelpSliceTest`). The calls are the methods of the facades and `health`; `config()` and `withLogger()` are not calls.
- **A key of `config/slen-sdk.php` feeds** the parameter named as it in camel case, else the one that name begins (`webhook_tolerance` → `webhookToleranceSeconds`); `log_channel`, `spans`, `span_level` feed none.
- **The floor is 8.3, held by running the package**: `bin/floor.php` (no PHPUnit) drives the SDK on its doubles on 8.3 and exits 1 naming the check that failed; one check is real: the Guzzle the SDK builds sends to a closed port of the loopback and must end in `ConnectionException` (Guzzle 8 on 8.3 in `php-floor`, Guzzle 7 in `php-guzzle7`); `src/`, `config/`, `bin/` and the PHP examples parse there (`php -l` in CI, `SourceFloorTest` early). The suite itself is 8.4 and runs on 8.4 and 8.5.
- **The suite reads `contract/` and `examples/` where `tests/Paths.php` says**, and fails — it does not skip — when the files are not there.
- **Nothing internal in any file of the package** (`SourceHygieneTest`, the patterns this tree was checked with before its first commit): no path of a machine, plan or its numbers, private repository, local stack, earlier name of the product or real-looking key; a test address is `https://api.test`. A guard spells its own patterns with character classes.
## Commands
```bash
# No PHP on the host: the toolchain runs in containers, from the root of this repository.
docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpunit # and composer:2 for 8.5
docker run --rm -v "$PWD":/app -w /app composer:2 composer quality
docker run --rm -v "$PWD":/app -w /app php:8.4-cli php bin/export-help-slice.php --check
# against the API's spec: add -v <dir of the spec>:/spec:ro -e SLEN_OPENAPI_SPEC=/spec/v1.yaml
```
The floor on 8.3 needs the dependencies 8.3 resolves: on a copy, `composer config --global platform.php 8.3.0 && composer update`, then `php:8.3-cli` runs `php -l` over `src config bin examples/help` and `php bin/floor.php` — what CI's `php-floor` does.
## Releasing
A release is an annotated tag `vX.Y.Z` on `master`: Packagist lists `slen/sdk` from this repository and its hook updates the package on every push, so the pushed tag is the release. No workflow publishes anything.
1. On a branch: what `## [Unreleased]` of `CHANGELOG.md` gathered becomes `## [X.Y.Z] - YYYY-MM-DD` under a new, empty `## [Unreleased]`, and the links at the bottom follow. Every PR writes its line under `[Unreleased]` as it lands. `composer.json` has no `version` field and gets none: Composer reads the version off the tag.
2. A pull request, green, merged to `master`.
3. On that commit of `master`: `git tag -a vX.Y.Z -m "slen-php X.Y.Z"`, then `git push origin vX.Y.Z`; Packagist shows `X.Y.Z` within minutes.
4. A pushed tag is never moved or deleted: Packagist and every `composer.lock` that took it hold its commit, so a fix is a new version.
## Help examples
`examples/help/<id>/` is code the console's Help & Docs prints: an article shows a region of `php.php`, never code of its own. `example.json` (`languages`, `regions`, `responses` — the answers in order) and `expect.json` (`calls` — every request, compared whole; `prints` — every line written) are the same files in every SDK whose language the example names.
- **`php.php` is included as it is**, in a scope of its own; it builds its SDK for real and hands it over on a line outside every region: `$slen = $offline($slen);`. `$offline` answers the same SDK on a `FakeHttpClient` fed from `responses`.
- **Regions**: the lines between `// docs:start <name>` and `// docs:end`, each marker on a line of its own, name `[a-z][a-z0-9-]*`, no nesting, a name once per file, never empty; exactly the regions `example.json` lists, in that order. What a reader does not see (`$offline`, `$recorded`) stays outside with a `// Harness:` comment, and a region reads only names a region of the same file brings in.
- **What an example shows**: the SDK as the code has it and without an address (`NAMED_NOWHERE`: no `baseUrl`, `base_url`, "base URL", not even to say it is left out); keys as `sl_live_…`; an idempotency key derived from the unit of work; options in their objects; no `else` (`match (true)` or independent `if`s); nothing the SDK no longer has (`RETIRED`) or never documented (`NOT_SHOWN`). PHP for the floor, 8.3: no `new Foo()->bar()`; no functions or classes at the top level.
- **`"laravel": true`**: the example gets the SDK from the container or the `Slen` facade; `HelpExamplesTest` runs it inside an application with `SlenServiceProvider`, the SDK put on the double with `extend()`.
- **A change to a call an example makes is finished when the example is green again**; the JSON changes where the contract is kept, and comes here as a copy.
## The help slice
`help-slice.json`: what the console's Help & Docs may say about this SDK without reading its source. `php bin/export-help-slice.php` prints it, `--write` writes it, `--check` exits 1 naming what differs (2 — a command line it does not know, a slice it could not build, a file it could not write or read); `--out <file>` names another file.
- **The bytes**: UTF-8, LF, two spaces, one line break at the end, non-ASCII and slashes not escaped; no date, no absolute path, no commit; lists sorted by `name` byte by byte unless in the order of the source. The same tree gives the same bytes on 8.4 and 8.5.
- **The top level**: `schema` (`2`: paths from the root of this package), `language` (`php`), `package` (`{ name, version: null, manifest: "composer.json", runtime, dependencies }` — `require` without `php`, then the `suggest` packages, optional), `client` (`Slen\Sdk\SlenSdk` / `Slen\Sdk\SlenConfig`), `options`, `config_file`, `calls`, `symbols`.
- **An option** is a parameter of `SlenConfig::__construct()`: `{ name, type, required, default, default_known, env, description }`; `default` is `null` or `{ code, value }`; `env` is always `[]` — the variables are the config file's.
- **`config_file`**: `{ path: "config/slen-sdk.php", name: "slen-sdk", keys }`, each key `{ key, type, default, env, option }`.
- **A symbol** is every class, interface, trait and enum `src/` autoloads: `{ name, kind, source, deprecated, internal, alias_of, modifiers, type_parameters, extends, implements, signature: null, members }`, `source` as `src/…`.
- **A call** is a public method reachable from the client, named as a caller writes it after the client (`operations.execute`, `health`): `{ name, symbol, member, deprecated, internal, signature, operation }`, `operation` copied from `contract/calls.json`, `null` for the two webhook calls; `config()` and `withLogger()` are not calls.
- **What holds it**: `HelpSliceTest` — committed equals fresh, and the slice against a real `SlenConfig`, the config file driven through the provider and the files of `src/`.
slen/sdk — the PHP/Laravel SDK: runs flows and reads their outcome, keeps end customers and their credits, checks webhooks. An entry point over a config, PSR-18/17 through discovery, PSR-3 for the journal, a facade per module, typed answers, an error mapper, a chain of HTTP decorators.
This repository is the package itself. contract/ and the JSON of examples/help/ are copies of the contract the SDKs share (PHP, TypeScript, Python); the contract is kept, and the SDKs are held against the API's OpenAPI spec, in the SDK's source repository.
| File | Description |
|---|---|
composer.json |
php: ^8.3; runtime psr/http-client, psr/http-factory, psr/http-message, psr/log, php-http/discovery — no HTTP client. Guzzle, Symfony HttpClient + nyholm/psr7, illuminate/*: dev and suggest. composer quality = phpcs, php-cs-fixer, phpstan (level max), rector. composer.lock is not committed (a library) |
src/SlenSdk.php, src/SlenConfig.php |
final readonly; SlenSdk::create(SlenConfig) builds the HTTP client once; withLogger(), operations(), executions(), customers(), webhooks(), health(), config(); readonly settings, checked in the constructor, with*() copies |
src/{IdempotencyKeys,LogMessages}.php, src/{Exceptions,Data,Observability,Testing}/ |
the keys; the journal's event names; the hierarchy; Health, Page; SlenSpan and the recorders; FakeHttpClient, RecordingSpanRecorder |
src/Shared/ |
@internal only: SdkFactory (the chain, the modules), Http/ (Enums/Endpoint, Data/ApiRequestDTO, Data/ApiResponseDTO; Client/ — HttpClientFactory, ApiClient, the decorators, RetryPolicy; Mappers/ErrorMapper; ApiCaller, CursorPager), Resources/BaseResource, Logging/Journal, Observability/SpanScope |
src/Modules/{Operations,Executions,Customers,Webhooks}/ |
facade, Factories/, Tasks/, Resources/, Data/, Enums/; Executions/Actions/WaitForExecutionAction; Webhooks/Services/{SignatureVerifier,EventParser} |
src/Laravel/ |
SlenServiceProvider (auto-discovered), the facade Slen |
config/slen-sdk.php |
the Laravel keys and their variables |
bin/floor.php |
the package run on PHP 8.3 without PHPUnit: what CI's php-floor executes |
bin/export-help-slice.php |
writes help-slice.json (--write, --check); a tool of the repository, run with the dev dependencies |
tests/Unit/ |
mirrors src/; ContractTest, SourceHygieneTest, ReadmeTest; Support/ — answers, doubles, a bare Laravel container |
tests/Paths.php |
where the suite and the tools find contract/ and examples/: one line each to change when they move |
tests/{HelpExamplesTest,HelpExampleFilesTest,HelpSliceTest,SourceFloorTest}.php |
the examples of examples/help run, and their php.php checked; the help slice; the syntax floor; outside composer quality |
contract/ |
calls.json, error-codes.json, unmapped-error-codes.json, span-attributes.json, webhook-vectors.json — copies, never edited here; SOURCE names the commit of the canon they were copied from |
examples/help/<id>/ |
php.php (this package's code of the example) beside the copied example.json and expect.json |
help-slice.json |
the help slice (schema 2), generated by bin/export-help-slice.php |
.github/ |
workflows/ci.yml (no secrets), dependabot.yml, AGENTS.md |
Endpoint plus values), sends it through ApiCaller and hands the answer to a resource, which turns the wire into a typed answer; a factory composes. A task only where a call has logic of its own: MoveCreditsTask (the credits), ExecuteOperationTask (the context, the key), GetExecutionStatusTask (the read timeout of a wait), DeleteCustomerTask (a 404 after no answer). A new call is an Endpoint and a facade method (and options, a type and a resource for a new answer). An Action only where a call is a loop (WaitForExecutionAction). Not Porto: no container, no UI layer — a modular client in the manner of Stripe's and openai-php's.…Options; outputs are typed answers. A data class has no fromArray(): reading the wire is the resource's job.final readonly, public promoted properties with defaults, no checks (ApiRequestDTO checks before the wire); a secret is private with a getter and __debugInfo(). No DTO suffix — the house rule's exception: the names are the public surface shared with TypeScript.Data without @internal, a webhook event, Observability\SlenSpan — has no DTO suffix either: its name is the TypeScript type's (the response schema of the spec), one name for both packages and the help. An internal data class (@internal) ends with DTO (PublicSurfaceTest).PublicSurfaceTest): the root, Exceptions, Data, Observability, Testing, Laravel, a module's facade, Data, Enums, Options. Shared and a module's Factories, Tasks, Resources, Actions, Services are @internal only; an internal class may sit in Data or Enums (CreditMovement). No old name is aliased.readonly and holds one factory; ApiCaller, tasks, resources and ErrorMapper are readonly too (BaseResource — abstract readonly). Composition, not inheritance: a task takes what it sends through in its constructor — ApiCaller, or ApiTransport for DeleteCustomerTask, which reads a refusal itself.WorkerModeTest, the house's one exception): SlenSdk::create(), WebhooksFacade::standalone() — named constructors; IdempotencyKeys::generate(), forJob(), SlenException::type() — pure functions; Slen::getFacadeAccessor() — Laravel's. No static property anywhere: a worker serves many requests.else/elseif and no if (! in src/, config/, bin/ or an example (SourceHygieneTest) — an early return, match, === false; booleans compared explicitly; closures, never fn (DisallowArrowFunction); one return type or ?T (HouseRulesTest; AttemptOutcomeDTO carries an answer or its absence).HouseRulesTest: new SpanScope only in Factories/), and ExecutionSpanMapper turns a run, ExecutionResult or ExecutionStatusView, into its SpanResultDTO in one place.HouseRulesTest): more travel together in a DTO (ExecuteRequestDTO, CreditMovementDTO, ContextClaimDTO, WaitStateDTO, RejectionDTO, SpanSubjectDTO); only the constructors of data classes (a Data namespace, SlenSpan), options, exceptions and SlenConfig take more. Time is one seam, Shared/Timing/TimingInterface (SystemTiming; FakeTiming in tests).FakeHttpClient, ScriptedApiClient, FakeTiming): the house's mirror test per class and createMock do not fit a library. A class of its own logic has its own test: MoveCreditsTaskTest, ExecuteOperationTaskTest, SignatureVerifierTest, EventParserTest. Plain TestCase, no Laravel application booted.@param says more than its type only there, and only a range, a format or what null/'0' means; an @internal docblock is tags only; // only for a constraint the line itself must show. The reasons are here, as rule — reason (SourceHygieneTest).SlenConfig checks itself (InvalidArgumentException): an empty key, a timeout not above zero, negative retries, a webhook tolerance below 1. baseUrl is not checked; a blank webhook secret is none — as in TypeScript.#[\SensitiveParameter], hidden by __debugInfo(): no dump, encoding or trace of a config carries them.SlenConfig::DEFAULT_BASE_URL (https://api.slen.ai, servers[0] of the spec); baseUrl only for tests and a local stack; the SDK adds /api. No README or example names it (ReadmeTest, HelpExampleFilesTest).HttpClientFactory builds the clients it knows with the config's timeouts: Guzzle (timeout, connect_timeout, http_errors: false), else Symfony Psr18Client (timeout, max_duration). Any other client is discovered and keeps its own (slen.config.timeout_not_applied, client); a client in the config is used as it is.timeout replaces the config's for each attempt: GuzzleTransport sends timeout (and PSR-18's http_errors/allow_redirects: false), SymfonyTransport uses withOptions() (timeout, max_duration; the pool is shared); Psr18Transport cannot, and the first attempt of a call that set timeout writes slen.config.timeout_not_applied (method, route, client).withOptions() came with symfony/http-client 6.2: an older Psr18Client gets Psr18Transport (a client the SDK builds keeps the config's timeouts). No conflict in composer.json — it would keep the SDK out of a Symfony 5.4 project that sends with Guzzle.LoggingClientDecorator → RetryClientDecorator → AuthClientDecorator → ApiClient. The journal sits outside the retry, so it writes how the call ended once; Auth sits inside, so no journal sees Authorization; it names the SDK slen-sdk-php/<tag> php/<version> (the version of a package is its tag).Endpoint plus values (ApiRequestDTO): the journal and every message name POST /v1/customers/{externalUserId}/charge, never a value — an external user id may be an email address.Endpoint (Shared/Http/Enums), its value the operation of contract/calls.json ('PUT /v1/customers/{externalUserId}/accounts/{creditType}'), not snake_case: the one table EndpointTest holds against the call table both ways, and no /v1/ or /health literal lives outside it.InvalidRequestException, invalid_request; nothing sent, nothing written): a missing or blank path value, a blank idempotency key — never swapped for a generated one, that turns the caller's retry into a second run — a timeout not finite or not above zero, retries below zero, a body that is not JSON. An empty body goes as {}.Accept: application/json, Content-Type only with a body, Idempotency-Key when there is one; the query drops nulls and writes booleans as true/false.TimeoutException when the cause chain says so (Symfony TimeoutExceptionInterface, Guzzle 8's ConnectTimeoutException, NetworkTimeoutException, ResponseTimeoutException, Guzzle 7's cURL errno 28 read only where getHandlerContext() exists, "timed out"), else ConnectionException; no code, no status, the transport's exception as previous. Guzzle 8 has no getHandlerContext(): called unguarded it is an \Error that escapes every catch (SlenException).ApiResponseDTO::$readable), so the journal knows its attempt; ApiCaller throws InvalidResponseException (invalid_response, with status and request id), never repeated.RetryPolicy): no answer, 429 and any 503, with the same key on every attempt. 500, 502, 504 are never repeated — a provider may have been paid. A call's retries (ApiRequestDTO::$retries) replaces the config's; 0 is one attempt.Retry-After (seconds or an HTTP date) up to 30 s is waited out; past 30 s the answer goes back at once as its error; without it 200 ms × 2ⁿ ±25 %, at most 5 s after the jitter.ApiResponseDTO::$afterNoAnswer says an earlier attempt got no answer: customers()->delete() takes a 404 customer_not_found after it as success.ErrorMapper::BY_CODE is contract/error-codes.json; ErrorMapperTest holds it both ways — every contract type to its class and parent, every class of Exceptions/ to a contract type. A change starts in the contract the SDKs share; the copy here comes with this table in one commit.ProviderException with a null code — the fallback never invents one, the README sends the caller to the code.errorCode, statusCode, details (error.details, or errors of framework validation), requestId (X-Request-Id, else error.request_id), retryAfterSeconds; type() is the contract's name.Health, Page, CreditPriceList, Customer, CustomerAccount, ExecutionResult, ExecutionStatusView, ExecutionTrace) end with public ?string $requestId = null, read the same way (ApiResponseDTO::requestId()); a resource takes it as the second argument of map()/page()/list(), the rows of a page and a nested account get null. wait() answers with the id of its last read; a webhook event has none.requestId of the eight options that carry timeout and retries (not WaitOptions, VerifyOptions, WebhooksOptions); ApiRequestDTO refuses one not ^[A-Za-z0-9._:-]{8,128}$ before the wire (the API swaps any other for its own without a word), ApiClient sends it as X-Request-Id on every attempt, and a call that got no answer throws with it; an answer's id wins wherever there is one. listAll()/entriesAll() refuse it: one id names one request.ApiCaller has one method: exchange() hands back the 2xx ApiResponseDTO or throws the refusal; ApiResponseDTO::data() is the data of its envelope (health() reads the whole body). A movement's or a run's key is the caller's or IdempotencyKeys::generate(), chosen by MoveCreditsTask and ExecuteOperationTask.InvalidResponseException, never a default — an answer of defaults hides a call that went through. Money and credits are decimal strings.IdempotencyKeys::generate() (UUID v4); forJob($jobId, $operation) = job:{id}:{operation}, one key for every attempt of a job.WorkerModeTest): 1 000 requests through withLogger() on ConstantHttpClient (it echoes the idempotency key as X-Request-Id) — every record in its own request's logger, under 64 KB kept after 100 to warm up; a copy leaves the shared SDK and the copies before it writing where they did; an SDK built in a clone of the container, as Octane's sandbox does, does not reach that clone — it keeps its clients and logger, and the application's logger may hold the base application, which lives as long as the worker.withLogger() is a new SlenSdk (SdkFactory::modules()): a new Journal, chain and facades over the same HttpContext (client, factories, pool) and span recorder; the original writes where it did. An SDK never changes once built: a worker shares one, a request's logger goes on a copy. Modules get ApiTransport (chain, ErrorMapper, timeout) and Telemetry.SlenConfig::$logger (silent unless given); Journal is final readonly, the one holder every link of one SDK shares. Names are LogMessages, the same names and fields as TypeScript's LOG_EVENTS; levels are PSR-3 (warning where TypeScript writes warn).slen.request (debug, every attempt), slen.retry (warning: the attempt that failed, delay_ms, reason no_answer / rate_limited / unavailable, status), slen.failed of a call that got no answer at all (attempts).slen.response (debug: status, duration_ms, request_id), slen.refused (info, a 4xx), slen.unauthorized (error: 401; 403 under insufficient_scope, invalid_api_key, not_a_member or no code), slen.failed (error: 5xx, unreadable 2xx; error_type, attempts).error_type is the contract's name of the type (Provider, InvalidResponse): one word for every language.Authorization, the webhook secret, the idempotency key, bodies, the server's error.message, path and query values. SlenSdkJournalTest plants values in each and searches every record. A logger that throws is swallowed.SlenSpan), attributes in snake_case, a null left out, false kept; none can hold a prompt or an answer. SlenSpanTest holds the class to contract/span-attributes.json and the names the calls write.SpanScope writes once per call: an answered run is refused when rejected, error when failed or uncertain, else ok; a thrown 402 or policy refusal is refused, anything else error. What a recorder throws is swallowed: telemetry never fails a run.operations.execute, operations.execute_async, executions.wait; the name is the call's, never the answer's. get(), trace(), customers and webhooks write none.FakeHttpClient (PSR-18; pushJson(), pushException(), requests(), lastRequest()) refuses a request with nothing queued with LogicException; RecordingSpanRecorder (spans(), last()) the same. Consumers test with them.operations()->execute() / executeAsync() (ExecuteOptions in an ExecuteRequestDTO): customer is context.user_id, creditType context.credit_type; a blank one, or a context naming another (42 and '42' are one), is refused before the wire — the task builds the request before the span opens. An empty input goes as {}, an empty context not at all.ExecutionResult (plus error, replayed) and ExecutionStatusView; the wire's operation is $operationCode. Statuses are the enum ExecutionStatus (isPending(), isFinished()); isRejected() is a reject node's answer, not "did not complete".mode: the test mode went, and the field left the trace with it.WaitForExecutionAction): start() builds the request once, before the facade opens the span; every read is sent with retries: 0 — the wait is the loop that repeats it — and, where the transport applies it (ApiTransport::$readTimeout), a timeout of min(config, max(5 s, time left)) — elsewhere a read asks for none and warns of nothing; the deadline is checked between reads.retry_after_seconds of the answer, or the Retry-After of a failed read, else 1 s doubling to 30 s; ±25 %, at least 0.25 s, never past the deadline, where the run is read once more. Sleep, clock and random are TimingInterface; RetryPolicy pauses the retry through it too.slen.wait.poll_failed and $onPollError, and the wait goes on; anything else is thrown at once, the listener's own exception too. Time up: the last state read, or the last failure when none was read.list() always sends view=customers (an old backend answers the page of accounts without it; a newer one takes only customers, else 422) and limit; create() and unblock() send {}, block() {} or {reason}; updateAccount() sends both settings. No span: moving credits is not a run.topUp, charge, adjust — one MoveCreditsTask over CreditMovementDTO) always sends credit_type (default), note only when given, the caller's key or a UUID v4, the same on every attempt. Refused before the wire: credits not ^-?\d+(\.\d{1,4})?$, a top-up or charge not above zero, a correction of zero, a blank credit type.final readonly Page (@template T: Page<Customer>, Page<CreditEntry>; data, nextCursor), read off the whole answer; no hasMore() — a full last page carries a cursor to an empty one. prices() answers a CreditPriceList (data), the wire's { data }. CustomerStatus and CreditEntryType are strict enums: a value of a later release is InvalidResponseException, as the spec draws them.listAll(), entriesAll()): the facade hands out the generator of CursorPager; nothing is sent before the first read. The first page is the options' cursor, then the nextCursor of the page before until one is null (at a boundary, one request of an empty page); limit is the size of a page, timeout and retries hold for each. Rows go one by one, the keys running on (yield $row, never yield from); a page whose nextCursor is the cursor it was asked with is InvalidResponseException after its rows. To resume, list()/entries() with a nextCursor.webhooks(); WebhooksFacade::standalone(new WebhooksOptions(…)) for a receiver without a key): no or blank header missing_header; one t of digits and at least one v1, else malformed_header; |now − t| over the tolerance outside_tolerance; HMAC-SHA256 of "{t}.{payload}" against every v1 with hash_equals, else no_matching_signature.VerifyOptions of a call: secret, else the configured one; toleranceSeconds (1 or more, else InvalidArgumentException); now, the moment of the check. A blank secret passed is InvalidArgumentException — never swapped for the configured one — and so is none at all. A refusal is slen.webhook.rejected (reason, age_seconds), never the body, header or secret.id, type, created_at, data; the four typed events read exactly their schema (WebhookEventsContractTest, with the spec); any other type is UnknownWebhookEvent. Not an event: InvalidResponseException webhook_payload_invalid; a failed signature: WebhookSignatureException with reason.SlenServiceProvider binds SlenConfig, Observability\SpanRecorderInterface and SlenSdk as singletons from config/slen-sdk.php. Every value goes through one reader, ContainerConfigReader (@internal, an instance over the container): blank is none; the timeouts are cast to float — an int cast makes 0.5 a zero, and zero is no timeout.config/slen-sdk.php has no base_url: the address is the SDK's constant, so the file an application publishes names none (ConfigFileTest). The provider still reads slen-sdk.base_url when a test or a local stack sets it; null or blank is the default address.log_channel is a channel of the application's log manager, else the application's LoggerInterface, else nowhere; spans: log is Psr3SpanRecorder on that logger at span_level, which it refuses when built if PSR-3 has no such level — a logger throws on one, better at wiring than after a run was paid for. Held by tests/Unit/Laravel/ on illuminate/container without the framework.Slen points at SlenSdk (@method for its modules and health()).tests/Unit/ContractTest.php): its rows are the lines of contract/calls.json; every public call is a row or a NOT_CALLS entry (the two webhook calls). Answers are sampled from the spec — the fullest and the leanest of each 2xx — and read through the call; the fullest leaves no field of a typed answer null. A call of every page is a row read to its first row; another test reads it over two pages of the spec, the second with the cursor of the first.tests/HelpExamplesTest.php) include each examples/help/*/php.php; HelpExampleFilesTest checks those files as the Node suite checks its own (regions, NOT_SHOWN, RETIRED, what a region leans on, PHP above the floor) — the checks across languages run where the contract is kept; $offline($slen) answers SlenSdk::create($slen->config()->withHttpClient(<the double>)) — key, logger and recorder stay the example's. A Laravel example gets SlenSdk from the container, the facade and app(), on the same double. Rules: "Help examples" below.ReadmeTest): every code block on the current calls and options, no address, keys sl_live_…, no else or elseif in a block — an early return, match (true) or independent ifs.composer.json, src/, config/, README.md, LICENSE, CHANGELOG.md: .gitattributes export-ignores the rest (tests/, bin/, the tools' configs, this file). So bin/ keeps rules of its own: the slice exporter's private statics and longer parameter lists stay, HouseRulesTest reads src/ only; bin/ still has no else and no if (! (SourceHygieneTest).CHANGELOG.md (Keep a Changelog) and a tag; composer validate --strict is clean; conflict keeps illuminate/support at 11 or newer (the README's Compatibility, held by ReadmeTest).src/: after a change there, php bin/export-help-slice.php --write and commit help-slice.json with it (HelpSliceTest). The calls are the methods of the facades and health; config() and withLogger() are not calls.config/slen-sdk.php feeds the parameter named as it in camel case, else the one that name begins (webhook_tolerance → webhookToleranceSeconds); log_channel, spans, span_level feed none.bin/floor.php (no PHPUnit) drives the SDK on its doubles on 8.3 and exits 1 naming the check that failed; one check is real: the Guzzle the SDK builds sends to a closed port of the loopback and must end in ConnectionException (Guzzle 8 on 8.3 in php-floor, Guzzle 7 in php-guzzle7); src/, config/, bin/ and the PHP examples parse there (php -l in CI, SourceFloorTest early). The suite itself is 8.4 and runs on 8.4 and 8.5.contract/ and examples/ where tests/Paths.php says, and fails — it does not skip — when the files are not there.SourceHygieneTest, the patterns this tree was checked with before its first commit): no path of a machine, plan or its numbers, private repository, local stack, earlier name of the product or real-looking key; a test address is https://api.test. A guard spells its own patterns with character classes.# No PHP on the host: the toolchain runs in containers, from the root of this repository.
docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpunit # and composer:2 for 8.5
docker run --rm -v "$PWD":/app -w /app composer:2 composer quality
docker run --rm -v "$PWD":/app -w /app php:8.4-cli php bin/export-help-slice.php --check
# against the API's spec: add -v <dir of the spec>:/spec:ro -e SLEN_OPENAPI_SPEC=/spec/v1.yaml
The floor on 8.3 needs the dependencies 8.3 resolves: on a copy, composer config --global platform.php 8.3.0 && composer update, then php:8.3-cli runs php -l over src config bin examples/help and php bin/floor.php — what CI's php-floor does.
A release is an annotated tag vX.Y.Z on master: Packagist lists slen/sdk from this repository and its hook updates the package on every push, so the pushed tag is the release. No workflow publishes anything.
## [Unreleased] of CHANGELOG.md gathered becomes ## [X.Y.Z] - YYYY-MM-DD under a new, empty ## [Unreleased], and the links at the bottom follow. Every PR writes its line under [Unreleased] as it lands. composer.json has no version field and gets none: Composer reads the version off the tag.master.master: git tag -a vX.Y.Z -m "slen-php X.Y.Z", then git push origin vX.Y.Z; Packagist shows X.Y.Z within minutes.composer.lock that took it hold its commit, so a fix is a new version.examples/help/<id>/ is code the console's Help & Docs prints: an article shows a region of php.php, never code of its own. example.json (languages, regions, responses — the answers in order) and expect.json (calls — every request, compared whole; prints — every line written) are the same files in every SDK whose language the example names.
php.php is included as it is, in a scope of its own; it builds its SDK for real and hands it over on a line outside every region: $slen = $offline($slen);. $offline answers the same SDK on a FakeHttpClient fed from responses.// docs:start <name> and // docs:end, each marker on a line of its own, name [a-z][a-z0-9-]*, no nesting, a name once per file, never empty; exactly the regions example.json lists, in that order. What a reader does not see ($offline, $recorded) stays outside with a // Harness: comment, and a region reads only names a region of the same file brings in.NAMED_NOWHERE: no baseUrl, base_url, "base URL", not even to say it is left out); keys as sl_live_…; an idempotency key derived from the unit of work; options in their objects; no else (match (true) or independent ifs); nothing the SDK no longer has (RETIRED) or never documented (NOT_SHOWN). PHP for the floor, 8.3: no new Foo()->bar(); no functions or classes at the top level."laravel": true: the example gets the SDK from the container or the Slen facade; HelpExamplesTest runs it inside an application with SlenServiceProvider, the SDK put on the double with extend().help-slice.json: what the console's Help & Docs may say about this SDK without reading its source. php bin/export-help-slice.php prints it, --write writes it, --check exits 1 naming what differs (2 — a command line it does not know, a slice it could not build, a file it could not write or read); --out <file> names another file.
name byte by byte unless in the order of the source. The same tree gives the same bytes on 8.4 and 8.5.schema (2: paths from the root of this package), language (php), package ({ name, version: null, manifest: "composer.json", runtime, dependencies } — require without php, then the suggest packages, optional), client (Slen\Sdk\SlenSdk / Slen\Sdk\SlenConfig), options, config_file, calls, symbols.SlenConfig::__construct(): { name, type, required, default, default_known, env, description }; default is null or { code, value }; env is always [] — the variables are the config file's.config_file: { path: "config/slen-sdk.php", name: "slen-sdk", keys }, each key { key, type, default, env, option }.src/ autoloads: { name, kind, source, deprecated, internal, alias_of, modifiers, type_parameters, extends, implements, signature: null, members }, source as src/….operations.execute, health): { name, symbol, member, deprecated, internal, signature, operation }, operation copied from contract/calls.json, null for the two webhook calls; config() and withLogger() are not calls.HelpSliceTest — committed equals fresh, and the slice against a real SlenConfig, the config file driven through the provider and the files of src/.How can I help you explore Laravel packages today?