> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xquik.com/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP SDK

> Use the Xquik PHP SDK to search tweets, export tweet replies to JSON Lines, CSV, or XLSX, post media tweets, upload media, send DMs, and run PHP X API workers.

<blockquote className="agent-llms-directive">
  For the complete documentation index, see <a href="/llms.txt">llms.txt</a>.
</blockquote>

Use the PHP SDK for PHP 8.1+ applications with named parameters, generated models, typed exceptions, retries, and file upload helpers. It is useful when a PHP app or worker needs to search tweets, scrape tweets or replies to JSON Lines, CSV, or XLSX, export followers, monitor tweets, post media tweets, upload media, send direct messages, or hand X API data to a queue, CRM, or reporting pipeline.

## Install

```bash theme={null}
composer require xquik/x-twitter-scraper
```

If your project needs the GitHub source directly, add the repository in `composer.json` as described in the [source repository](https://github.com/Xquik-dev/x-twitter-scraper-php).

## Authenticate

```bash theme={null}
export X_TWITTER_SCRAPER_API_KEY="xq_YOUR_KEY_HERE"
```

## Basic Example

Search tweets and write durable JSON Lines handoff rows:

```php theme={null}
<?php

use XTwitterScraper\Client;

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: 'xq_YOUR_KEY_HERE'
);

$page = $client->x->tweets->search(
  q: 'from:username webhook OR SDK',
  limit: 10,
);

foreach ($page->tweets as $tweet) {
  $row = [
    'tweet_id' => $tweet->id,
    'text' => $tweet->text,
    'author_username' => $tweet->author?->username,
    'created_at' => $tweet->createdAt,
  ];

  echo json_encode($row, JSON_THROW_ON_ERROR) . PHP_EOL;
}
```

## Workflow: Search Tweets to JSON Lines, CSV, or XLSX

Use this workflow when a PHP worker, Laravel command, Symfony console command, or cron job needs tweet search results in a durable file for a queue, warehouse loader, analyst CSV export, XLSX workbook, or CRM enrichment step.

`$client->x->tweets->search()` calls `GET /x/tweets/search`. The generated parameter model is `TweetSearchParams`, and the service method exposes the same request controls as named arguments: `q`, `limit`, `cursor`, `sinceTime`, `untilTime`, and `queryType`.

```php theme={null}
<?php

use XTwitterScraper\Client;
use XTwitterScraper\PaginatedTweets;
use XTwitterScraper\SearchTweet;
use XTwitterScraper\X\Tweets\TweetSearchParams\QueryType;

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: ''
);

$query = 'from:username webhook OR SDK';
$cursor = null;
$pageIndex = 0;
$headers = [
  'source',
  'query',
  'tweet_id',
  'text',
  'author_id',
  'author_username',
  'author_name',
  'created_at',
  'like_count',
  'reply_count',
  'retweet_count',
  'quote_count',
  'view_count',
  'bookmark_count',
  'is_note_tweet',
  'page_index',
  'page_cursor',
  'next_cursor',
  'has_next_page',
];
$jsonlHandle = fopen('xquik-tweet-search.jsonl', 'wb');
$csvHandle = fopen('xquik-tweet-search.csv', 'wb');

if (false === $jsonlHandle || false === $csvHandle) {
  throw new RuntimeException('Could not open tweet search handoff files');
}

try {
  fputcsv($csvHandle, $headers);

  do {
    $pageCursor = $cursor;

    /** @var PaginatedTweets $page */
    $page = $client->x->tweets->search(
      q: $query,
      cursor: $cursor,
      queryType: QueryType::LATEST,
    );

    foreach ($page->tweets as $tweet) {
      /** @var SearchTweet $tweet */
      $row = [
        'source' => 'xquik.php.search',
        'query' => $query,
        'tweet_id' => $tweet->id,
        'text' => $tweet->text,
        'author_id' => $tweet->author?->id,
        'author_username' => $tweet->author?->username,
        'author_name' => $tweet->author?->name,
        'created_at' => $tweet->createdAt,
        'like_count' => $tweet->likeCount ?? 0,
        'reply_count' => $tweet->replyCount ?? 0,
        'retweet_count' => $tweet->retweetCount ?? 0,
        'quote_count' => $tweet->quoteCount ?? 0,
        'view_count' => $tweet->viewCount ?? 0,
        'bookmark_count' => $tweet->bookmarkCount ?? 0,
        'is_note_tweet' => $tweet->isNoteTweet ?? false,
        'page_index' => $pageIndex,
        'page_cursor' => $pageCursor,
        'next_cursor' => '' === $page->nextCursor ? null : $page->nextCursor,
        'has_next_page' => $page->hasNextPage,
      ];
      $csvRow = [];
      foreach ($headers as $header) {
        $csvRow[] = $row[$header];
      }

      fputcsv($csvHandle, $csvRow);
      fwrite(
        $jsonlHandle,
        json_encode($row, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
      );
    }

    $cursor = $page->hasNextPage ? $page->nextCursor : null;
    $pageIndex++;
  } while (null !== $cursor && '' !== $cursor);
} finally {
  fclose($jsonlHandle);
  fclose($csvHandle);
}
```

### Request Mapping

<CardGroup cols={2}>
  <Card title="q" icon="search">
    PHP argument `q` maps to REST `q`. Use it for an X search query such as `from:username`, a keyword, hashtag, or boolean operator query.
  </Card>

  <Card title="limit" icon="list">
    PHP argument `limit` maps to REST `limit`. Use it as a 1 to 200 upper bound for a bounded pull. If `$page->hasNextPage` is true, keep the same `q`, filters, `queryType`, and `limit` when you continue with `$page->nextCursor`.
  </Card>

  <Card title="cursor" icon="arrow-right">
    PHP argument `cursor` maps to REST `cursor`. Pass the opaque cursor from `$page->nextCursor` to request the next page.
  </Card>

  <Card title="sinceTime" icon="clock">
    PHP argument `sinceTime` maps to REST `sinceTime`. Use it as the ISO 8601 lower bound for tweet creation time.
  </Card>

  <Card title="untilTime" icon="clock">
    PHP argument `untilTime` maps to REST `untilTime`. Use it as the ISO 8601 upper bound for tweet creation time.
  </Card>

  <Card title="queryType" icon="sliders-horizontal">
    PHP argument `queryType` maps to REST `queryType`. Use `QueryType::LATEST` for chronological search or `QueryType::TOP` for engagement-ranked search.
  </Card>
</CardGroup>

### Returned Data & Handoff

`$client->x->tweets->search()` returns `PaginatedTweets`. Use `$page->tweets` for the tweet array, `$page->hasNextPage` to decide whether another page exists, and `$page->nextCursor` as the checkpoint for the next request. For bounded pulls that return fewer tweets than `limit`, pass `$page->nextCursor` back as `cursor` with the same query, filters, `queryType`, and `limit`.

Each `SearchTweet` includes typed properties such as `$id`, `$text`, `$author`, `$createdAt`, `$likeCount`, `$replyCount`, `$retweetCount`, `$quoteCount`, `$bookmarkCount`, `$viewCount`, and `$isNoteTweet` when available. Project `$page->tweets` into JSON Lines and CSV rows with `tweet_id`, `author_username`, engagement counts, `page_index`, `page_cursor`, `next_cursor`, and `has_next_page` so workers can resume safely or load the same records into XLSX, CRM, warehouse, or agent workflows.

Tweet search costs 1 credit per tweet returned. If remaining credits cannot cover a bounded `limit` request, the API can return fewer tweets; if 0 paid results are affordable, it returns `402 insufficient_credits`. The SDK retries connection errors, timeouts, 408, 409, 429, and 5xx responses 2 times by default. For explicit `limit` pulls, resume with the same query, filters, `queryType`, and `limit`; only `cursor` changes.

## Workflow: Follower Export to CSV, JSON, or XLSX

Use this workflow when a PHP worker, Laravel command, Symfony console command, or cron job needs an owned follower list for CRM import, warehouse loading, account scoring, analyst CSV, XLSX delivery, or a resumable JSON handoff.

`$client->extractions->estimateCost()` maps to `POST /extractions/estimate`, `$client->extractions->run()` maps to `POST /extractions`, `$client->extractions->retrieve()` maps to `GET /extractions/{id}`, and `$client->extractions->exportResults()` maps to `GET /extractions/{id}/export`. For follower exports, `follower_explorer` requires `targetUsername`.

<Info>
  `$client->extractions->run()` returns the queued `202 Accepted` receipt from `POST /extractions`: REST `id`, `toolType`, and `status: "running"` as PHP `$job->id`, `$job->toolType`, and `$job->status`. Store `$job->id` immediately, then poll `$client->extractions->retrieve()` before reading pages or calling `$client->extractions->exportResults()`. Credit reservation happens after the job starts. If available credits changed since `estimateCost`, the run can fetch only the affordable count before export or mark the job `failed` with `insufficient_credits`.
</Info>

```php theme={null}
<?php

use XTwitterScraper\Client;
use XTwitterScraper\Extractions\ExtractionEstimateCostParams\ToolType as EstimateToolType;
use XTwitterScraper\Extractions\ExtractionExportResultsParams\Format as ExportFormat;
use XTwitterScraper\Extractions\ExtractionRunParams\ToolType as RunToolType;

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: ''
);

$targetUsername = 'username';
$estimate = $client->extractions->estimateCost(
  toolType: EstimateToolType::FOLLOWER_EXPLORER,
  targetUsername: $targetUsername,
);

if (!$estimate->allowed) {
  throw new RuntimeException("Follower export requires {$estimate->creditsRequired} credits.");
}

$job = $client->extractions->run(
  toolType: RunToolType::FOLLOWER_EXPLORER,
  targetUsername: $targetUsername,
);

$completed = false;

for ($attempt = 0; $attempt < 120; $attempt++) {
  $statusPage = $client->extractions->retrieve($job->id, limit: 1);
  $status = $statusPage->job['status'] ?? null;

  if ('completed' === $status) {
    $completed = true;
    break;
  }

  if ('failed' === $status) {
    throw new RuntimeException('Follower export failed.');
  }

  sleep(10);
}

if (!$completed) {
  throw new RuntimeException('Follower export did not complete before timeout.');
}

$after = null;
$handle = fopen('xquik-followers.jsonl', 'wb');

if (false === $handle) {
  throw new RuntimeException('Could not open xquik-followers.jsonl');
}

try {
  do {
    $page = $client->extractions->retrieve($job->id, after: $after, limit: 1000);

    foreach ($page->results as $user) {
      fwrite(
        $handle,
        json_encode($user, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL
      );
    }

    $after = $page->hasMore ? $page->nextCursor : null;
  } while (null !== $after && '' !== $after);
} finally {
  fclose($handle);
}

file_put_contents(
  'xquik-followers.csv',
  $client->extractions->exportResults($job->id, format: ExportFormat::CSV),
);
file_put_contents(
  'xquik-followers.json',
  $client->extractions->exportResults($job->id, format: ExportFormat::JSON),
);
file_put_contents(
  'xquik-followers.xlsx',
  $client->extractions->exportResults($job->id, format: ExportFormat::XLSX),
);
```

Persist `$job->id`, `$targetUsername`, `$estimate->estimatedResults`, and `$estimate->source` before polling so another queue worker can resume without rerunning the extraction. `$client->extractions->retrieve()` returns `$page->results`, `$page->hasMore`, and `$page->nextCursor`; pass `$page->nextCursor` back as `after` to page through large follower lists. Map exported `User ID` or row `xUserId` as the CRM unique key. Use `xquik-followers.jsonl` for queue replay or warehouse loads, `xquik-followers.json` for app ingestion, `xquik-followers.csv` for CRM import, and `xquik-followers.xlsx` for analyst handoff. Cost: 1 credit per follower extracted or returned. Exports are free after the extraction job exists.

## Workflow: Tweet Replies to CSV, JSON, or XLSX

Use this workflow when a PHP worker needs every reply on a public tweet in a durable file for moderation review, customer support triage, analyst export, or a warehouse loader.

`$client->extractions->estimateCost()` and `$client->extractions->run()` map to the extraction workflow. For tweet replies, use `ToolType::REPLY_EXTRACTOR`; `reply_extractor` requires `targetTweetID`. `$client->extractions->retrieve()` returns `results`, `hasMore`, and `nextCursor` for pagination. `$client->extractions->exportResults()` returns the completed job as a string and supports `CSV`, `JSON`, and `XLSX` export formats.

Reuse the polling, JSON Lines pagination, and export structure from the follower workflow. Only the tool type, target field, and filenames change:

```php theme={null}
$targetTweetID = '1893704267862470862';
$estimate = $client->extractions->estimateCost(
  toolType: EstimateToolType::REPLY_EXTRACTOR,
  targetTweetID: $targetTweetID,
);

if (!$estimate->allowed) {
  throw new RuntimeException('Insufficient credits for reply extraction.');
}

$job = $client->extractions->run(
  toolType: RunToolType::REPLY_EXTRACTOR,
  targetTweetID: $targetTweetID,
);

// Run the same polling loop and JSONL pagination loop from the follower workflow.
// Set the JSON Lines destination to fopen('xquik-replies.jsonl', 'wb').

file_put_contents(
  'xquik-replies.csv',
  $client->extractions->exportResults($job->id, format: ExportFormat::CSV),
);
file_put_contents(
  'xquik-replies.json',
  $client->extractions->exportResults($job->id, format: ExportFormat::JSON),
);
file_put_contents(
  'xquik-replies.xlsx',
  $client->extractions->exportResults($job->id, format: ExportFormat::XLSX),
);
```

Cost: 1 credit per reply extracted or returned. Store `$job->id` on the queue job, ticket, or warehouse batch before polling so another worker can resume with `$client->extractions->retrieve()`. Keep `$page->nextCursor` as the checkpoint and pass it back as `after` when you stream replies to JSON Lines. Use `xquik-replies.jsonl` for queue replay or warehouse loads, `xquik-replies.json` for app ingestion, `xquik-replies.csv` for CRM import, and `xquik-replies.xlsx` for analyst handoff.

## Workflow: Post Media Tweets and DM Attachments

Use this workflow when a PHP app needs to publish a media tweet, reply with media, or send a direct message with an uploaded local file. `POST /x/tweets` accepts public media URLs through `media`. Send up to 4 image URLs or exactly 1 MP4 video URL up to 100 MB, and do not mix video with other media. For replies, set `replyToTweetID` to the parent tweet ID. `$client->x->media->upload()` maps to `POST /x/media`; use its `$media->mediaID` only for the one-item `mediaIDs` array on `$client->x->dm->send()`.

Use `$client->x->tweets->raw->create()` to capture the durable write action. Send a unique `Idempotency-Key`, store `id`, `request.hash`, `billing`, `result`, and `statusUrl`, then poll while `terminal` is false. Retry only when `safeToRetry` is true, using a new key.

```php theme={null}
<?php

use XTwitterScraper\Client;
use XTwitterScraper\Core\FileParam;

/**
 * @param array<string,mixed> $payload
 *
 * @return array<string,mixed>
 */
function createTweetHandoff(Client $client, array $payload): array
{
  $response = $client->x->tweets->raw->create(params: $payload);
  $action = json_decode(
    (string) $response->getBody(),
    true,
    flags: JSON_THROW_ON_ERROR,
  );
  $result = $action['result'] ?? [];

  return [
    'status' => $action['status'],
    'terminal' => $action['terminal'],
    'safe_to_retry' => $action['safeToRetry'],
    'write_action_id' => $action['id'],
    'request_hash' => $action['request']['hash'],
    'tweet_id' => $result['id'] ?? $action['tweetId'] ?? null,
    'charged' => $action['billing']['charged'],
    'charged_credits' => $action['billing']['chargedCredits'],
    'poll' => $action['terminal'] ? null : $action['statusUrl'],
  ];
}

$client = new Client(
  apiKey: getenv('X_TWITTER_SCRAPER_API_KEY') ?: ''
);

$tweetHandoff = createTweetHandoff($client, [
  'account' => '@username',
  'text' => 'Shipping the weekly X API video.',
  'media' => ['https://static.example.com/reports/x-api-export.mp4'],
]);

$replyHandoff = createTweetHandoff($client, [
  'account' => '@username',
  'text' => 'Here is the chart behind the update.',
  'media' => ['https://static.example.com/reports/reply-chart.png'],
  'replyToTweetID' => $tweetHandoff['tweet_id'] ?? '1893704267862470862',
]);

$localFile = fopen('handoff.png', 'rb');

if (false === $localFile) {
  throw new RuntimeException('Could not open handoff.png');
}

try {
  $media = $client->x->media->upload(
    account: '@username',
    file: FileParam::fromResource($localFile),
  );
} finally {
  fclose($localFile);
}

$dm = $client->x->dm->send(
  '44196397',
  account: '@username',
  text: 'Here is the requested asset.',
  mediaIDs: [$media->mediaID],
);

$dmHandoff = [
  'message_id' => $dm->messageID,
  'media_id' => $media->mediaID,
  'user_id' => '44196397',
  'account' => '@username',
  'status' => 'sent',
];

fwrite(
  STDOUT,
  json_encode(
    [
      'tweet_handoff' => $tweetHandoff,
      'reply_handoff' => $replyHandoff,
      'dm_handoff' => $dmHandoff,
    ],
    JSON_THROW_ON_ERROR
  ) . PHP_EOL
);
```

Store `write_action_id` and `charged_credits`, then poll [Get Write Action Status](/api-reference/x-write/get-write-action-status) before retrying a pending tweet or reply. Store `tweet_id` on the CMS, queue job, or agent state after confirmed writes. Store `$dm->messageID`, `$media->mediaID`, `account`, `user_id`, and send status on the support ticket or CRM note. Keep DM body text in private systems. Shared logs, public artifacts, queue status, and agent handoffs should store `message_id`, optional `media_id`, `account`, `user_id`, and send status instead of full DM bodies. Leave generated `replyToMessageID` unset even if SDK params expose it; the REST endpoint rejects DM reply threading. Text-only tweet and reply writes cost 30 credits. Tweet media adds 2 credits per started MB across attached files. Uploading media costs 10 credits, and sending the DM costs 10 credits. Do not pass uploaded `$media->mediaID` values to `$client->x->tweets->create()`; that method uses `media` with public media URLs.

## Error Handling

The SDK throws subclasses of `XTwitterScraper\Core\Exceptions\APIException`.

<CardGroup cols={2}>
  <Card title="400 Bad Request" icon="triangle-alert">
    Throws `BadRequestException`.
  </Card>

  <Card title="401 Unauthorized" icon="lock">
    Throws `AuthenticationException`.
  </Card>

  <Card title="403 Permission Denied" icon="shield">
    Throws `PermissionDeniedException`.
  </Card>

  <Card title="404 Not Found" icon="circle-x">
    Throws `NotFoundException`.
  </Card>

  <Card title="422 Validation Error" icon="file-warning">
    Throws `UnprocessableEntityException`.
  </Card>

  <Card title="429 Rate Limited" icon="gauge">
    Throws `RateLimitException`.
  </Card>

  <Card title="5xx Server Error" icon="server">
    Throws `InternalServerException`.
  </Card>
</CardGroup>

```php theme={null}
<?php

use XTwitterScraper\Core\Exceptions\APIConnectionException;
use XTwitterScraper\Core\Exceptions\APIStatusException;

try {
  $account = $client->account->retrieve();
} catch (APIConnectionException $e) {
  fwrite(STDERR, "Connection failed\n");
} catch (APIStatusException $e) {
  fwrite(STDERR, $e->getMessage() . PHP_EOL);
}
```

## Pagination

Paginated responses expose fields such as `hasNextPage`. Pass the endpoint's cursor fields when requesting more pages.

```php theme={null}
<?php

$page = $client->x->tweets->search(q: 'xquik', limit: 20);

if ($page->hasNextPage) {
  fwrite(STDERR, "More results are available\n");
}
```

## Webhooks & References

* [Search Tweets](/api-reference/x/search-tweets)
* [Create Tweet](/api-reference/x-write/create-tweet)
* [Upload Media](/api-reference/x-write/upload-media)
* [Send Direct Message](/api-reference/x-write/send-dm)
* [Create Monitor](/api-reference/monitors/create)
* [Webhook Overview](/webhooks/overview)
* [Verify HMAC Signatures](/webhooks/verification)
* [REST API Overview](/api-reference/overview)
* [Source Repository](https://github.com/Xquik-dev/x-twitter-scraper-php)
