> ## 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 for tweet search, exports & X automation

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

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

Use PHP 8.1+ named parameters and typed exceptions in Laravel or Symfony workers. Search tweets, export followers or replies, post media, upload files, and send DMs.

| PHP task | SDK call | Save |
| - | - | - |
| Search tweets | `$client->x->tweets->search()` | `$page->nextCursor` |
| Export followers | `$client->extractions->run()` | `$job->id` |

## Install

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

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

## 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`. 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 the inclusive ISO bound on every page.
  </Card>

  <Card title="untilTime" icon="clock">
    PHP argument `untilTime` maps to REST `untilTime`. Use the exclusive ISO bound on every page.
  </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`. Workers can then resume 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.');
}

$cursor = 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, cursor: $cursor, limit: 1000);

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

    $cursor = $page->hasMore ? $page->nextCursor : null;
  } while (null !== $cursor && '' !== $cursor);
} 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),
);
```

Store `$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 `cursor` 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. Pass it back as `cursor` 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. 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`. 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. 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.

`$client->x->dm->deleteMessage()` [deletes a DM](/api-reference/x-write/delete-dm).

## 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="triangle-alert">
    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)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.