# PHP SDK

Use the PHP SDK to moderate content, manage assets and handle events from your server, with Guzzle and PSR-7 streams. It requires PHP 8.2.35, 8.3.36, 8.4.27 or 8.5.12, or a later release.

## Install

```bash
composer require calmlens/sdk
```

## Moderate content

Keep credentials on your server. Create an API key and copy your project ID in the portal; see [Authentication](/docs/guides/authentication) for the permissions each call needs. `$client->assets->create()` queues processing; `$client->assets->classify()` and `$client->text->classify()` wait for the decision. Audio and video always run in the background. See [Waiting for the decision](/docs/guides/classifying-assets#waiting-for-the-decision) for supported content, webhooks and polling.

```php
<?php
require 'vendor/autoload.php';

$client = new CalmLens\ServerClient(
    apiKey: getenv('CALMLENS_API_KEY'),
    projectId: getenv('CALMLENS_PROJECT_ID'),
);
$asset = $client->text->classify(['text' => 'Hello world']);
if (in_array($asset['classification']['moderation']['status'], ['allowed', 'limited'], true)) {
    // Publish the comment; reduce the reach of a limited one.
}
```

Methods and option keys use camelCase. Responses are associative arrays with the API's field names. See the [PHP SDK reference](/docs/sdk/php/reference) for client settings and each feature's options in detail.

Publish `allowed` content normally and `limited` content with reduced reach. Serve an `age-restricted` one only to viewers your app allows, by passing `'viewerClaims' => ['ageConfirmed' => true]` to the CDN methods or to `$client->assets->createSignedUrl()`; a signed URL or your API key alone doesn't serve it. See [Age-restricted assets](/docs/guides/delivering-assets#age-restricted-assets). Treat a status you don't recognise as not allowed, and treat failed classification the same way. See [Reading classification results](/docs/guides/classification-results).

### Waiting for a decision

For queued assets, use webhooks or `$client->assets->waitForDecision(['assetId' => $asset['id'], 'timeout' => 60])`. It retrieves the asset until its `classification.moderation.status` is no longer `pending`, or `classification.status` is `failed`, and returns it. If there is no decision within `timeout` seconds (default 60), it throws `TimeoutException` (`sdk/timeout`) with the asset's statuses in `details`.

## Uploads

Supply a name and exactly one of `content`, `part` or `url`. Content accepts `plainText`, `base64`, or `data`/`bytes` with a concrete MIME `type`. Binary data may be a string, PHP stream resource or PSR-7 stream. The SDK reads the size of strings and seekable streams. A stream whose size it can't read, such as a pipe, is sent without one unless you set `sizeInBytes`; a multipart part from such a stream must set it. The caller closes input streams.

```php
$file = fopen('photo.jpg', 'rb');
try {
    $asset = $client->assets->create([
        'name' => 'photo.jpg',
        'content' => ['data' => $file, 'type' => 'image/jpeg'],
    ]);
} finally {
    fclose($file);
}
$signed = $client->assets->createSignedUrl(['assetId' => $asset['id']]);
```

Use `$client->assets->uploadPart()` for later multipart parts. Non-final parts must be at least 5 MiB; multipart images total 10–20 MiB. Whether the upload was started with `create()` (queued) or `classify()` (wait for the decision) is fixed by the first part, so later parts can come from any process. See [Multipart uploads](/docs/guides/multipart-uploads).

## Upload presets

Pass `uploadPresetId` to `$client->assets->create()` and `$client->assets->classify()` to apply an [upload preset](/docs/guides/upload-presets)'s folder, visibility, file types and size limit. To let a browser upload through a preset that requires tokens, sign a single-use token on your server with `$client->uploadTokens->sign(['uploadPresetId' => ...])`, after authenticating and authorising the browser user. Signing happens locally with your API key and makes no request; tokens last 600 seconds by default.

## CDN methods

Fetch retained content directly from your backend with `$client->cdn->serve()`:

```php
$response = $client->cdn->serve(['assetIdentifier' => 'asset_5Gm2Rk8XqT3wLp9VbN4c', 'resource' => 'original']);
try {
    if ($response->getStatusCode() !== 200) {
        throw new RuntimeException('CDN request failed');
    }
    while (!$response->getBody()->eof()) {
        echo $response->getBody()->read(8192);
    }
} finally {
    $response->getBody()->close();
}
```

`$client->cdn->serve()` returns a streaming PSR-7 response containing raw content bytes, not the asset report, including HTTP error responses. Inspect its status before using the body, and close it; for text assets (such as those created by `text->classify()`), decode the body as UTF-8 text. The request uses your server API key; private reads require **Download assets** permission. Use [signed URLs](/docs/guides/delivering-assets#signed-urls) for private content in browsers.

Upload with `'processingOptions' => ['keepContentAfterProcessing' => true]` to retain content. The CDN delivers by the current `classification.moderation.status`, and still requires available content and authorised access. See [Delivery while moderation runs](/docs/guides/asset-lifecycle#delivery-while-moderation-runs).

`$client->cdn->getAssetUrl()` builds a URL. `resource` defaults to `rendered`, which takes `lens` and `transform`; `original` is the uploaded file as it is, and `placeholder` a small blurred PNG served publicly for every asset, private ones included, whatever its moderation status. `'viewerClaims' => ['ageConfirmed' => true]` lets an age-restricted asset load from its URL, for viewers your app allows; without it, the CDN refuses the asset with [`403 viewer/age-confirmation-required`](/docs/api/cdn#error-viewer-age-confirmation-required), even with a signed URL or your API key.

## Methods

Every method takes one options array; see [Options and return values](/docs/sdk/php/reference#options-and-return-values).

| Method                                                                        | Purpose                                                                                                                                                                                                                                                                                              |
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text->classify()`                                                            | Classify text and wait for the decision.                                                                                                                                                                                                                                                             |
| `assets->create()`                                                            | Upload content and queue processing.                                                                                                                                                                                                                                                                 |
| `assets->classify()`                                                          | Upload content and wait for the decision where supported.                                                                                                                                                                                                                                            |
| `assets->uploadPart()`                                                        | Send the next part of a multipart upload.                                                                                                                                                                                                                                                            |
| `assets->retrieve()`                                                          | Fetch one asset.                                                                                                                                                                                                                                                                                     |
| `assets->waitForDecision()`                                                   | Retrieve an asset until it has a decision; see [Waiting for a decision](#waiting-for-a-decision).                                                                                                                                                                                                    |
| `assets->list()` / `assets->each()`                                           | List one page of assets, or iterate over every matching asset.                                                                                                                                                                                                                                       |
| `assets->search()`                                                            | Search assets by meaning and by name, ID, external ID, description and tags, best match first; see [Asset search](/docs/guides/search).                                                                                                                                                              |
| `assets->listChildren()` / `assets->eachChild()`                              | List an asset's extracted children.                                                                                                                                                                                                                                                                  |
| `assets->update()`                                                            | Update asset fields.                                                                                                                                                                                                                                                                                 |
| `assets->move()`                                                              | Move an asset to a folder, or to the project root with `'folderId' => null`.                                                                                                                                                                                                                         |
| `assets->retry()`                                                             | Retry an eligible processing failure.                                                                                                                                                                                                                                                                |
| `assets->explain()`                                                           | Fetch the full policy evidence for a decision (which rules and findings produced it), or with `'rerun' => true` moderate the asset again under the current policy.                                                                                                                                   |
| `assets->decide()`                                                            | Record a reviewer's `decision` (`allowed`, `limited`, `age-restricted`, `blocked` or `purged`) with a required `note`. An asset pending moderation can only be blocked or purged, and purging needs `'confirmPurge' => true`; see [`POST /assets/:assetId/decision`](/docs/api/assets#decide-asset). |
| `assets->createSignedUrl()`                                                   | Sign a private asset's CDN URL.                                                                                                                                                                                                                                                                      |
| `assets->deleteContent()`                                                     | Remove stored content and keep the asset record and report.                                                                                                                                                                                                                                          |
| `assets->delete()`                                                            | Delete the asset record and its content.                                                                                                                                                                                                                                                             |
| `assets->batch()`                                                             | Delete up to 500 assets (`'delete'`) or remove their stored content (`'remove-content'`) in one queued request; see [`POST /projects/:projectId/assets/batch`](/docs/api/assets#run-asset-batch-operation).                                                                                          |
| `folders->list()`, `retrieve()`, `create()`, `update()`, `move()`, `delete()` | Manage folders. `folders->list()` returns one page of folders with the `ancestors` needed to show their paths.                                                                                                                                                                                       |
| `hashLists->list()`, `retrieve()`, `create()`, `update()`, `delete()`         | Manage [hash lists](/docs/guides/hash-lists) that image uploads are compared with.                                                                                                                                                                                                                   |
| `hashLists->listEntries()`, `addEntry()`, `removeEntry()`                     | Manage the hashes in a hash list.                                                                                                                                                                                                                                                                    |
| `events->list()` / `events->eachPage()` / `events->each()`                    | Read one page of the project event feed, follow its cursor page by page, or iterate over its events.                                                                                                                                                                                                 |
| `ensureLens()` / `ensureLenses()`                                             | Create or update versioned lens definitions; successful results are cached per client.                                                                                                                                                                                                               |
| `cdn->getAssetUrl()` / `cdn->serve()`                                         | Build a CDN URL, or stream content.                                                                                                                                                                                                                                                                  |
| `uploadTokens->sign()`                                                        | Sign an upload token locally, without a request.                                                                                                                                                                                                                                                     |

## Pagination

Every list returns a page with `items`, `nextCursor` and `hasMore`. Pass a page's `nextCursor` as `cursor` to get the next one, and treat it as opaque. `pageSize` is 1 to 100 (50 by default), and `ordering` directions are lowercase, such as `[['field' => 'createdAt', 'direction' => 'desc']]`. `$client->assets->each()`, `$client->assets->eachChild()`, `$client->events->each()` and `$client->events->eachPage()` return generators that follow the cursor for you.

`$client->events->list()` without a `cursor` returns no events and a `nextCursor` for the current time. Save each event page's `nextCursor` after handling its events:

```php
foreach ($client->events->eachPage(['cursor' => $savedCursor, 'pageSize' => 100]) as $page) {
    foreach ($page['items'] as $event) {
        handleEvent($event);
    }
    $savedCursor = $page['nextCursor'];
}
```

If a page with more to follow returns a cursor the iteration already sent, the generator throws `sdk/invalid-response` (with the cursor in `details['cursor']`) instead of looping. See [Reading the feed](/docs/api/events#reading-the-feed).

## Errors and retries

Catch `CalmLens\CalmLensException` and inspect `errorCode`, `status`, `requestId`, `details` and `headers`. `requestId` is the `X-Request-Id` response header, or the error body's `requestId` without it; include it when you contact support. Compare `errorCode` with the constants in `CalmLens\ErrorCodes` and `CalmLens\SdkErrorCodes`. Subclasses in `CalmLens\Exception` name the kind of failure, such as `NotFoundException`, `RateLimitException`, `TimeoutException` and `ConnectionException`, and `retryAfter()` reads a `Retry-After` header.

| Failure                                                                                                                                    | What the SDK does                                                                                                                                | What you do                                                                |
| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| Connection failure or timeout                                                                                                              | Retries reads and uploads up to `maxRetries` times (default 2), with backoff from 0.5 seconds doubling up to 8 seconds, half of each wait random | Once retries run out, retry later: `isRetryable()` is true                 |
| `429`, `502`, `503`, `504` or [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress) | Retries the same requests, waiting for `Retry-After` (seconds or an HTTP date) when the API sends one                                            | A `Retry-After` over 60 seconds is thrown to you instead: wait, then retry |
| `waitForDecision` runs out of time                                                                                                         | Throws `TimeoutException` (`sdk/timeout`)                                                                                                        | Wait again, or rely on a webhook                                           |
| Failure of another write, such as `assets->update()`                                                                                       | Retried only when you pass `idempotencyKey` in `requestOptions`                                                                                  | Pass one when repeating the change must be safe                            |
| Upload from a stream that can't seek                                                                                                       | Sent once                                                                                                                                        | Upload again from the start                                                |
| Upload retried from another process                                                                                                        | Each upload sends a new idempotency key, reused for its own retries                                                                              | Pass the same `idempotencyKey` in `requestOptions`                         |
| Redirect                                                                                                                                   | Not followed; a 3xx fails with `sdk/http-error` and its status                                                                                   | Check `baseUrl` and `cdnBaseUrl`                                           |
| `$client->cdn->serve()` failure                                                                                                            | Retries the same failures, then returns the last response                                                                                        | Check the response status                                                  |

| Setting            | Default     | Limits                                 |
| ------------------ | ----------- | -------------------------------------- |
| `timeout`          | 120 seconds | Each request attempt                   |
| `uploadTimeout`    | None        | Each binary upload attempt             |
| `connectTimeout`   | 10 seconds  | Opening a connection                   |
| `maxResponseBytes` | 32 MiB      | The API response body the client reads |

Set them, and `maxRetries`, on the client, or pass them per call in a `requestOptions` key of the method's options array, which also takes an `idempotencyKey`:

```php
$client->assets->update([
    'assetId' => $assetId,
    'name' => 'Renamed',
    'requestOptions' => ['idempotencyKey' => $key, 'timeout' => 30],
]);
```

See [Retrying requests safely](/docs/guides/error-handling#retrying-requests-safely), and the reference's [Errors](/docs/sdk/php/reference#errors) and [Retries and idempotency](/docs/sdk/php/reference#retries-and-idempotency) for every exception class and the backoff.

## Webhooks

```php
try {
    $event = CalmLens\Webhooks::parseEvent(
        file_get_contents('php://input'),
        $_SERVER['HTTP_X_CALMLENS_SIGNATURE'] ?? null,
        $_SERVER['HTTP_X_CALMLENS_TIMESTAMP'] ?? null,
        getenv('CALMLENS_WEBHOOK_SECRET'),
    );
} catch (CalmLens\Exception\WebhookVerificationException $e) {
    http_response_code($e->errorCode === CalmLens\SdkErrorCodes::INVALID_WEBHOOK_SIGNATURE ? 401 : 400);
    exit;
}
```

A forged, tampered or stale delivery (more than 300 seconds from your clock) throws `WebhookVerificationException` with `sdk/invalid-webhook-signature`: respond with 401. A verified body that isn't a CalmLens event throws it with `sdk/invalid-webhook-payload`: respond with 400.

Always verify the raw body before parsing. To read the signature and timestamp from the request, pass `null` for both and `headers: getallheaders()` (or `$_SERVER`, or a PSR-7 request's `getHeaders()`); names match in any case, and repeated headers are joined with `,`.

The event has the same fields as an item from `$client->events->list()`: `id`, `projectId`, `type`, `severity`, `title`, `description`, `assetId`, `asset`, `facts` and `createdAt`, with `description`, `assetId` and `asset` set to `null` when absent. Deduplicate with `$event['id']`; event facts are in `$event['facts']`. `Webhooks::isTest($event)` recognises the registered test fact. Unknown event types, fact keys and additional fields are preserved; a body without a string `id`, `type` and `projectId` and a numeric `createdAt` is not an event. See [Webhooks](/docs/guides/webhooks), and [the Laravel example](https://github.com/CalmLens/sdks/tree/main/examples/comment-board-laravel) for container registration, comment moderation and signed webhook handling.

## Laravel

Keep the credentials in `.env` and read them through a config file, `config/calmlens.php`:

```php
<?php
return [
    'api_key' => env('CALMLENS_API_KEY'),
    'project_id' => env('CALMLENS_PROJECT_ID'),
    'webhook_secret' => env('CALMLENS_WEBHOOK_SECRET'),
];
```

Register one client for the app in a service provider's `register()`, so controllers and jobs receive it by type:

```php
$this->app->singleton(\CalmLens\ServerClient::class, fn () => new \CalmLens\ServerClient(
    apiKey: (string) config('calmlens.api_key'),
    projectId: (string) config('calmlens.project_id'),
));
```

Inject it where content arrives, and publish only once the decision allows it:

```php
public function store(Request $request, \CalmLens\ServerClient $client)
{
    $input = $request->validate(['comment' => ['required', 'string', 'max:5000']]);
    try {
        $asset = $client->text->classify(['text' => $input['comment']]);
    } catch (\CalmLens\CalmLensException $error) {
        // Never publish unmoderated content when moderation is unavailable.
        return back()->withInput()->withErrors(['comment' => 'Moderation is unavailable. Please try again.']);
    }
    if (!in_array($asset['classification']['moderation']['status'] ?? null, ['allowed', 'limited'], true)) {
        return back()->withInput()->withErrors(['comment' => 'This comment cannot be published yet.']);
    }
    // Save the comment with $asset['id'].
}
```

Receive webhooks on a route in `routes/api.php`, which has no session or CSRF check. Verify the raw body, not parsed input:

```php
Route::post('/webhooks/calmlens', function (Request $request) {
    try {
        $event = \CalmLens\Webhooks::parseEvent(
            $request->getContent(),
            $request->header(\CalmLens\Webhooks::SIGNATURE_HEADER),
            $request->header(\CalmLens\Webhooks::TIMESTAMP_HEADER),
            (string) config('calmlens.webhook_secret'),
        );
    } catch (\CalmLens\CalmLensException $error) {
        return response()->json(['code' => $error->errorCode], 401);
    }
    if (\CalmLens\Webhooks::isTest($event) || !Cache::add('calmlens-event:' . $event['id'], true, now()->addDay())) {
        return response()->noContent();
    }
    // Act on the event, such as publishing or removing the post for $event['assetId'].
    return response()->noContent();
});
```

The [Laravel example](https://github.com/CalmLens/sdks/tree/main/examples/comment-board-laravel) puts these together in a runnable comment board with tests.

## Command-line tool

See [Local development](/docs/guides/local-development) to install the CLI, forward signed events and trigger test uploads.

## Testing

Use `CalmLens\Testing\MockServerClient` for offline tests. See [PHP testing](/docs/sdk/php/testing).
