# PHP SDK

`datalumo/php` is the official PHP client for the Datalumo API. It handles authentication, retries, streaming, and turns every response into a typed object, so you are not parsing JSON by hand.

On Laravel? Use the [Laravel package](/docs/laravel) instead. It is built on this client and adds model syncing and Blade components.

Requires PHP 8.2 or newer.

## Install

```bash
composer require datalumo/php
```

```php
use Datalumo\Client;

$client = new Client(
    token: getenv('DATALUMO_TOKEN'),
    organisation: getenv('DATALUMO_ORG'),
);
```

`organisation` is your organisation's id, not its name. Create an API key and copy the id from the **API keys** page in your dashboard, and give the key the permissions you need (see [Authentication](/docs/authentication)).

Self-hosting Datalumo? Pass `baseUrl: 'https://datalumo.example.com'`.

## Check the connection

```php
$me = $client->me()->get();

$me->organisation->name;
$me->token->abilities;      // ['pages.write', 'search']
$me->token->sourceScope;    // null when the key can reach every source

foreach ($me->sources as $source) {
    echo $source->slug;
}
```

A good first call in any setup script: it proves the key works and tells you exactly what it may do.

## Push content

```php
$page = $client->pages('docs')->upsert([
    'external_id' => 'post-42',
    'name' => 'Hello world',
    'content' => '# Hello world',
    'content_mime' => 'markdown',
    'source_url' => 'https://example.com/hello-world',
    'meta' => ['author' => 'Jane'],
]);

$page->status;   // "pending" until indexing finishes
```

`docs` is the slug (or id) of the source you are writing to. `external_id` is your own id for the record: send the same one again and Datalumo updates that page instead of creating a second one.

`content_mime` accepts the short forms `markdown`, `md`, `html`, `plain`, and `text` as well as the full types.

Up to 50 pages at a time:

```php
$pages = $client->pages('docs')->batchUpsert([
    ['external_id' => 'post-1', 'name' => 'One', 'content' => '…'],
    ['external_id' => 'post-2', 'name' => 'Two', 'content' => '…'],
]);
```

Removing one:

```php
$client->pages('docs')->delete('post-42');
```

### Listing what is there

```php
$list = $client->pages('docs')->list(['status' => 'failed']);

foreach ($list->data as $page) {
    echo $page->externalId.': '.$page->error;
}
```

Walking every page:

```php
$cursor = null;

do {
    $list = $client->pages('docs')->list(array_filter([
        'per_page' => 100,
        'cursor' => $cursor,
    ]));

    foreach ($list->data as $page) {
        // …
    }

    $cursor = $list->nextCursor;
} while ($list->hasMorePages());
```

### Waiting for indexing

Pushing is not indexing. A write is accepted straight away and the content becomes searchable a little later. In a script or a test, you can wait for it:

```php
$page = $client->pages('docs')->waitUntilIndexed('post-42', timeoutSeconds: 60);
```

It throws if indexing fails or the wait runs out. Do not use it in a web request; for anything user facing, treat "pending" as normal and let the dashboard show progress.

## Search

```php
$result = $client->widgets($widgetId)->search([
    'query' => 'how does pricing work',
    'limit' => 5,
]);

foreach ($result->data as $hit) {
    echo $hit->name;      // title
    echo $hit->url;       // link to show
    echo $hit->snippet;   // matching text
    echo $hit->score;
    echo $hit->externalId;
}

$result->sessionId;       // pass along to tie clicks to this search
```

`$widgetId` is the widget's own id, without the `organisation/` prefix used by the browser embed. Search does not use answer credits.

Optional parameters mirror the [search endpoint](/docs/search): `filters`, `sort`, `limit`, and `session_id`.

## Chat and summaries

```php
$reply = $client->widgets($widgetId)->chat([
    'query' => 'How do refunds work?',
    'conversation_id' => $conversationId,   // optional, continues a conversation
]);

$reply->text;
$reply->conversationId;
$reply->sources;   // the pages the answer used
```

Streaming, so visitors see words as they arrive:

```php
$stream = $client->widgets($widgetId)->chatStream(['query' => 'How do refunds work?']);

foreach ($stream->text() as $chunk) {
    echo $chunk;
    flush();
}

$conversationId = $stream->conversationId();
```

If you want the individual events (status updates, errors) rather than just the words:

```php
foreach ($stream as $event) {
    if ($event->isStatus()) { /* "searching" and friends */ }
    if ($event->isToken()) { echo $event->text(); }
    if ($event->isDone()) { /* $event->data['conversation_id'] */ }
}
```

`summarize()` and `summarizeStream()` work the same way and produce a short answer over search results.

Both chat and summarize use answer credits. When they run out, the call throws `QuotaExceededException` and the body carries free search hits, so you can still show results.

## Signed visitors and conversations

To let a logged-in user pick up their previous conversations, sign their id on your server with the widget's signing secret:

```php
use Datalumo\Identity;

$user = Identity::userPayload((string) $user->id, $signingSecret);

$client->widgets($widgetId)->chat([
    'query' => $message,
    'user' => $user,
]);
```

Then their history is available:

```php
$conversations = $client->widgets($widgetId)->conversations();

$conversations->list($userId, $hash);
$conversations->get($conversationId, $userId, $hash);
$conversations->keep($conversationId, true, $userId, $hash);
$conversations->delete($conversationId, $userId, $hash);
$conversations->deleteAll($userId, $hash);
```

`Identity::sign($userId, $secret)` gives you the hash on its own, and `Identity::conversationParams()` the `user` and `hash` pair these methods expect. The signing secret stays on your server.

## Analytics and widget config

```php
$client->widgets($widgetId)->recordEvent([
    'type' => 'click',
    'session_id' => $result->sessionId,
    'meta' => ['url' => $hit->url, 'rank' => 0],
]);

$config = $client->widgets($widgetId)->config();   // colours, greeting, language
```

Recording clicks against the search's `session_id` is what makes analytics in your dashboard tell the whole story: which query led to which result.

## Errors

Every failure throws something that extends `DatalumoException`, so you can catch broadly or precisely:

| Exception | When |
|---|---|
| `AuthenticationException` | 401, key missing or invalid |
| `AuthorizationException` | 403, key lacks a permission or the source is out of scope |
| `QuotaExceededException` | 402, no answer credits left |
| `NotFoundException` | 404, unknown source, page, or widget |
| `ValidationException` | 422, something in your payload was rejected |
| `RateLimitException` | 429, too many requests |
| `DatalumoException` | Anything else, including network problems |

```php
use Datalumo\Exceptions\ValidationException;
use Datalumo\Exceptions\DatalumoException;

try {
    $client->pages('docs')->upsert($page);
} catch (ValidationException $e) {
    print_r($e->errors());       // field => messages
} catch (DatalumoException $e) {
    echo $e->status().': '.$e->getMessage();
}
```

`RateLimitException::retryAfterSeconds()` tells you how long to wait, and `body()` gives the decoded response on any of them.

## Timeouts and retries

Rate limited requests are retried automatically, up to three times, honouring `Retry-After`. Chat and summarize are left out of that by default, because a retry there could spend another answer credit.

Change any of it:

```php
use Datalumo\Client;
use Datalumo\HttpClientOptions;

$client = new Client(
    token: $token,
    organisation: $org,
    options: new HttpClientOptions(
        timeoutSeconds: 30,
        connectTimeoutSeconds: 10,
        maxRetries: 3,
        retryOn: [429],
        retryChatAndSummarize: false,
        logger: $psrLogger,
    ),
);
```

Pass your own configured Guzzle client as `guzzle` if you need proxies, custom middleware, or want to fake responses in tests.

## Related

- [Introduction](/docs/introduction) for the other ways to integrate
- [Laravel package](/docs/laravel), built on this client
- [Authentication](/docs/authentication) for API keys, permissions, and widget access
- [Pages](/docs/pages) · [Search](/docs/search) · [Chat](/docs/chat) · [Events](/docs/events) for the endpoints behind each method
