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

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 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 for supported content, webhooks and polling.

<?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 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. Treat a status you don't recognise as not allowed, and treat failed classification the same way. See Reading 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.

$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.

Upload presets

Pass uploadPresetId to $client->assets->create() and $client->assets->classify() to apply an upload preset'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():

$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 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.

$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, even with a signed URL or your API key.

Methods

Every method takes one options array; see Options and return values.

MethodPurpose
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.
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.
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.
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.
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 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:

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.

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.

FailureWhat the SDK doesWhat you do
Connection failure or timeoutRetries reads and uploads up to maxRetries times (default 2), with backoff from 0.5 seconds doubling up to 8 seconds, half of each wait randomOnce retries run out, retry later: isRetryable() is true
429, 502, 503, 504 or 409 idempotency/request-in-progressRetries the same requests, waiting for Retry-After (seconds or an HTTP date) when the API sends oneA Retry-After over 60 seconds is thrown to you instead: wait, then retry
waitForDecision runs out of timeThrows 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 requestOptionsPass one when repeating the change must be safe
Upload from a stream that can't seekSent onceUpload again from the start
Upload retried from another processEach upload sends a new idempotency key, reused for its own retriesPass the same idempotencyKey in requestOptions
RedirectNot followed; a 3xx fails with sdk/http-error and its statusCheck baseUrl and cdnBaseUrl
$client->cdn->serve() failureRetries the same failures, then returns the last responseCheck the response status
SettingDefaultLimits
timeout120 secondsEach request attempt
uploadTimeoutNoneEach binary upload attempt
connectTimeout10 secondsOpening a connection
maxResponseBytes32 MiBThe 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:

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

See Retrying requests safely, and the reference's Errors and Retries and idempotency for every exception class and the backoff.

Webhooks

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, and the Laravel example  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
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:

$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:

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:

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  puts these together in a runnable comment board with tests.

Command-line tool

See Local development to install the CLI, forward signed events and trigger test uploads.

Testing

Use CalmLens\Testing\MockServerClient for offline tests. See PHP testing.

Last updated

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies