Twitter API in PHP: get X data with cURL, Guzzle and Laravel
To get Twitter/X data in PHP, send HTTPS requests with cURL or Guzzle, put the API key in a header, decode the JSON and follow the cursor page by page; the official X API bills $0.005 per post.
Short answer: X ships official SDKs only for Python and TypeScript, so PHP reaches the official X API through community clients like TwitterOAuth, a developer account and prepaid credits: $0.005 per post, $0.015 with its author from November 22, 2026. For public data, use Sorsa API: one key, 20 tweets per request from $0.07 per 1,000; 100 free requests cover 2,000.
- Endpoint
POST /user-tweets- Page size
- 20 tweets per request
- Price
- from $0.07 per 1,000 tweets
#Is there an official X API SDK for PHP?
No. X publishes official SDKs for Python and TypeScript only, and its tools page lists three community PHP clients that X doesn't maintain: twitter-api-v2-php, bird-elephant and TwitterOAuth (X's tools and libraries (opens in a new tab)). TwitterOAuth is the most installed of the three, about 240K Composer installs a month. Its version 9.0.0, released on November 1, 2026, speaks only the v2 API and needs PHP 8.3 or later.
All three are clients of the official X API, so every call they make needs a developer account, an app's keys and prepaid credits. New developers get no free tier, and X bills each object a response returns: $0.005 per post and $0.010 per user, up to 3M post reads a month (X's price table (opens in a new tab)). From November 22, 2026 the authors and media pulled in through expansions are billed at the same rates (X's notice of October 23 (opens in a new tab)), so the usual TwitterOAuth search pays for every author it attaches:
use Abraham\TwitterOAuth\TwitterOAuth;
$x = new TwitterOAuth($apiKey, $apiSecret, null, $bearerToken);
$posts = $x->get('tweets/search/recent', [
'query' => 'laravel lang:en',
'max_results' => 100,
'expansions' => 'author_id',
]);
// 100 posts: $0.50. From Nov 22, 2026, with authors: up to $1.50
Older answers point further back. Google's first result for this search is J7mbo/twitter-api-php, a wrapper for v1.1 calls whose GitHub repository is archived and was last changed in April 2020. Every current rate, cap and date of the official route is in X API pricing.
What the official route needs before the first call
- A developer account at console.x.com, opened with an X account and X's Developer Agreement accepted.
- An app with its API key, API secret and bearer token, which X shows only once (X's access guide (opens in a new tab)).
- Credits bought in advance and spent per post and per user returned.
- OAuth 1.0a user tokens, an access token and its secret, for anything that posts; TwitterOAuth's four-argument constructor takes them.
None of it is needed to read public posts and profiles. The next section does that with one key and the cURL extension that most PHP builds already include.
#Get tweets in PHP with one API key
To read X data from PHP, use Sorsa API, a Twitter (X) API and scraper: one ApiKey header instead of OAuth, 20 requests per second on every plan, free accounts included, and timelines and search from $0.07 per 1,000 tweets. Every response is plain JSON that json_decode turns into arrays, with the author's full profile inside every tweet. It never asks for your X login, so your X account carries no risk.
-
Get a key. Sign up and copy your API key from the dashboard. The first 100 requests are free, need no card and never expire.
-
Keep the key out of the code. Set it as an environment variable, or in
.envfor Laravel, and call the API only from server-side PHP: a key in browser JavaScript is visible to every visitor.Terminal export SORSA_API_KEY="your_key" # PowerShell: $env:SORSA_API_KEY="your_key" -
Fetch an account's latest tweets. Send the username to
POST /user-tweets(opens in a new tab) with the key in theApiKeyheader. Try it from the terminal first:Terminal curl -X POST "https://api.sorsa.io/v3/user-tweets" \ -H "ApiKey: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"username": "php_release_notes"}' -
Call it from PHP. Plain cURL needs no Composer package. With Guzzle, end
base_uriwith a slash and start the path without one, or the/v3part is dropped.<?php $key = getenv('SORSA_API_KEY') ?: 'YOUR_API_KEY'; $ch = curl_init('https://api.sorsa.io/v3/user-tweets'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'ApiKey: ' . $key, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['username' => 'php_release_notes']), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $data = json_decode($body, true, flags: JSON_THROW_ON_ERROR); if ($status !== 200) { exit("HTTP $status: {$data['message']}\n"); } foreach ($data['tweets'] as $tweet) { echo $tweet['created_at'], ' ', mb_substr($tweet['full_text'], 0, 60), "\n"; } -
Read the page. Each response holds up to 20 tweets and a
next_cursorwhile more remain. The values below are demo data in the documented shape.Response200 OK { "tweets": [ { "id": "1985432176540981234", "full_text": "PHP 8.5.12 is out with two security fixes. Upgrade today.", "created_at": "2026-11-01T18:42:07Z", "lang": "en", "likes_count": 42, "retweet_count": 7, "reply_count": 3, "quote_count": 1, "view_count": 3810, "bookmark_count": 5, "is_reply": false, "user": { "id": "1592041833471827968", "username": "php_release_notes", "display_name": "PHP Release Notes", "followers_count": 1840, "verified": false } } ], "next_cursor": "DAABCgABF7Y8kQ3wAAA" }
Each tweet arrives with its counts and its author, so a PHP script sets no expansions or field lists. These are the fields most scripts read:
| Field | What it holds |
|---|---|
id | The tweet's ID: 19 digits, sent as a string |
full_text | The whole text of the post |
created_at | The time in ISO 8601, UTC, ready for new DateTimeImmutable() |
likes_count, retweet_count, reply_count, quote_count | Engagement counts |
view_count, bookmark_count | Views and bookmarks |
user | The author's profile: 19 fields such as username and followers_count |
quoted_status, retweeted_status | The quoted or reposted tweet, in the same shape |
id- What it holds
- The tweet's ID: 19 digits, sent as a string
full_text- What it holds
- The whole text of the post
created_at- What it holds
- The time in ISO 8601, UTC, ready for
new DateTimeImmutable()
likes_count,retweet_count,reply_count,quote_count- What it holds
- Engagement counts
view_count,bookmark_count- What it holds
- Views and bookmarks
user- What it holds
- The author's profile: 19 fields such as
usernameandfollowers_count
quoted_status,retweeted_status- What it holds
- The quoted or reposted tweet, in the same shape
#One request function with retries
Send every call through one function that retries only what a retry can fix. A 429 means more than 20 requests started within one second on your key, and a 5xx or a dropped connection is a temporary fault; both clear on their own. sorsa() retries them up to four times, waiting 1, 2, 4 and 8 seconds, and sends GET parameters as repeated keys, the form the batch endpoints below read. The rest of this guide builds on it.
<?php
const SORSA_API = 'https://api.sorsa.io/v3';
const RETRY = [429, 500, 502, 503, 504];
/** Build a query string; a list becomes a repeated key: a=1&a=2. */
function query(array $params): string
{
$parts = [];
foreach ($params as $name => $value) {
foreach ((array) $value as $one) {
$parts[] = rawurlencode($name) . '=' . rawurlencode($one);
}
}
return implode('&', $parts);
}
/** Send one request; retry 429, 5xx and network errors up to 4 times. */
function sorsa(string $method, string $path, array $params = []): array
{
$url = SORSA_API . $path;
$headers = ['ApiKey: ' . (getenv('SORSA_API_KEY') ?: 'YOUR_API_KEY')];
$options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30];
if ($method === 'GET') {
$url .= $params ? '?' . query($params) : '';
} else {
$headers[] = 'Content-Type: application/json';
$options[CURLOPT_POSTFIELDS] = json_encode($params);
}
$options[CURLOPT_HTTPHEADER] = $headers;
for ($attempt = 0; ; $attempt++) {
$ch = curl_init($url);
curl_setopt_array($ch, $options);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 0: no answer
$again = in_array($status, [0, ...RETRY], true);
if (!$again || $attempt === 4) {
break;
}
sleep(2 ** $attempt); // 1, 2, 4, 8 seconds
}
if ($raw === false) {
throw new RuntimeException('Network error: ' . curl_error($ch));
}
$data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
if ($status !== 200) {
throw new RuntimeException("HTTP $status: " . ($data['message'] ?? ''));
}
return $data;
}
A 401 or a 403 is never worth a retry: the key is wrong or missing, or its requests are used up, and the message field says which. Failed requests are never charged, so a retry costs nothing, and every Sorsa API endpoint only reads, so repeating a POST can't create anything twice. The function runs on PHP 8.0 and later. It skips curl_close(), which has done nothing since PHP 8.0, because cURL handles now close themselves.
#Page through results with next_cursor
A cursor is an opaque key to the next page of results: send next_cursor back unchanged and stop when a response comes without one. Timelines and POST /search-tweets (opens in a new tab) return up to 20 tweets per page, and a page can hold fewer while more remain, so the item count never tells you the end has come. A generator hands out one page at a time and keeps memory flat however long the job runs.
require 'sorsa.php';
/** Yield pages of a POST endpoint until the cursor runs out. */
function pages(string $path, array $body, int $maxPages = 100): Generator
{
for ($i = 0; $i < $maxPages; $i++) {
$page = sorsa('POST', $path, $body);
yield $page;
if (empty($page['next_cursor'])) {
return;
}
$body['next_cursor'] = $page['next_cursor'];
}
}
foreach (pages('/search-tweets', ['query' => 'laravel lang:en']) as $page) {
foreach ($page['tweets'] as $tweet) {
echo $tweet['id'], ' @', $tweet['user']['username'], "\n";
}
}
Keep the query and order unchanged while one cursor runs, and treat $maxPages as a budget: 100 pages is 100 requests and at most 2,000 tweets. GET endpoints take the cursor in the query string instead. GET /followers (opens in a new tab) returns up to 200 profiles a page:
$params = ['username' => 'php_release_notes'];
$followers = [];
do {
$page = sorsa('GET', '/followers', $params);
array_push($followers, ...$page['users']);
$params['next_cursor'] = $page['next_cursor'] ?? null;
} while ($params['next_cursor']);
Two jobs have complete scripts of their own: download all tweets from a user resumes a whole timeline from a saved cursor, and the guide to export Twitter followers writes follower lists to CSV.
#Fetch 100 tweets or profiles per request
When you already hold tweet IDs or usernames, send them in batches. POST /tweet-info-bulk (opens in a new tab) takes up to 100 tweet IDs or links, and GET /info-batch (opens in a new tab) takes up to 100 usernames. Batch responses have no cursor: one response holds the whole batch.
$tweets = [];
foreach (array_chunk($tweetIds, 100) as $batch) {
$res = sorsa('POST', '/tweet-info-bulk', ['tweet_links' => $batch]);
array_push($tweets, ...$res['tweets']);
}
$profiles = [];
foreach (array_chunk($usernames, 100) as $batch) {
$res = sorsa('GET', '/info-batch', ['usernames' => $batch]);
array_push($profiles, ...$res['users']);
}
A request costs the same whatever it returns, so a batch of 100 tweets costs what one tweet costs: from $0.014 per 1,000 tweets, more than 250x less than the official X API's $5.00. The 100 free requests cover 10K tweets this way.
Good to know
GET /info-batchreads a list as a repeated key,usernames=a&usernames=b, the form its OpenAPI reference defines. PHP'shttp_build_query()writesusernames%5B0%5D=a&usernames%5B1%5D=binstead, and so do Guzzle's arrayqueryoption and Laravel'sHttp::get($url, $array), which build the query the same way. Use thequery()helper above, orGuzzleHttp\Psr7\Query::build(), which writes repeated keys.
#Run batches in parallel at 19 requests a second
PHP runs requests side by side with curl_multi, and one process reaches Sorsa API's 20 requests per second per key once the work splits into independent requests. Batches split cleanly, because each one stands alone. A cursor chain can't be split, since every page needs the cursor from the page before, so run separate accounts or date windows side by side instead. parallel() starts at most 19 requests a second, one under the limit, and puts any 429 or 5xx back in line.
/** POST many bodies side by side, starting at most $rate a second. */
function parallel(string $path, array $bodies, float $rate = 19): array
{
$multi = curl_multi_init();
$queue = array_keys($bodies);
$tries = $results = [];
$next = microtime(true);
$running = 0;
while ($queue || $running) {
if ($queue && microtime(true) >= $next) {
$key = array_shift($queue);
$ch = curl_init(SORSA_API . $path);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'ApiKey: ' . (getenv('SORSA_API_KEY') ?: 'YOUR_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($bodies[$key]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_PRIVATE => (string) $key,
]);
curl_multi_add_handle($multi, $ch);
$next = max($next, microtime(true) - 1 / $rate) + 1 / $rate;
}
curl_multi_exec($multi, $running);
while ($done = curl_multi_info_read($multi)) {
$ch = $done['handle'];
$key = curl_getinfo($ch, CURLINFO_PRIVATE);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$tries[$key] = ($tries[$key] ?? 0) + 1;
if (in_array($status, [0, ...RETRY], true) && $tries[$key] < 5) {
$queue[] = $key; // back in line, it waits its turn
} else {
$results[$key] = json_decode(curl_multi_getcontent($ch), true);
}
curl_multi_remove_handle($multi, $ch);
}
curl_multi_select($multi, 0.01);
}
return $results;
}
$bodies = array_map(fn ($batch) => ['tweet_links' => $batch],
array_chunk($tweetIds, 100));
$tweets = array_merge(...array_column(parallel('/tweet-info-bulk', $bodies),
'tweets'));
At 19 starts a second, 100 batches move 10K tweets in about 5 seconds, 1,900 tweets a second. Keep one parallel() loop per key: two processes running it side by side start twice the limit between them. Guzzle's Pool looks like the tool for this, but its concurrency option caps requests in flight, not starts per second, so a pool of quick responses can still cross 20 a second and lean on retries.
#Laravel: the same calls with the Http client
Laravel's Http client wraps Guzzle, so a Laravel app needs no extra package. One macro sets the base URL, the key and a retry rule that repeats only 429s, 5xx responses and dropped connections; every controller, job or Artisan command then calls Http::sorsa().
// config/services.php: 'sorsa' => ['key' => env('SORSA_API_KEY')],
// .env: SORSA_API_KEY=YOUR_API_KEY
// app/Providers/AppServiceProvider.php
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
// inside the class
public function boot(): void
{
Http::macro('sorsa', fn () => Http::baseUrl('https://api.sorsa.io/v3')
->withHeaders(['ApiKey' => config('services.sorsa.key')])
->timeout(30)
->retry(5, fn (int $attempt) => 1000 * 2 ** ($attempt - 1),
fn (Throwable $e) => $e instanceof ConnectionException
|| ($e instanceof RequestException
&& ($e->response->status() === 429
|| $e->response->serverError()))));
}
retry() sleeps 1, 2, 4 and 8 seconds between attempts and throws a RequestException when the attempts run out or the status isn't worth a retry, such as a 401 or a 403, so a failed call never lands in the cache. Run long jobs, such as thousands of batch requests, from a queued job or an Artisan command rather than inside a page request.
A cached feed spends requests by the clock, not by the visitor. The planner turns the number of feeds and the cache time into requests a day and a month, and the Sorsa API plan that holds them.
- Requests a day
- 288
- Requests a month
- 8,640
- Sorsa API plan that holds it
- Starter
- 100 free requests last
- about 8 h
whatever the traffic
30 days
10K requests a month, 86% used
no card, they never expire
Every visitor in the same 5 minutes reads the cached copy, so 100 page views and 1M page views cost the same 288 requests a day.
Requests depend on how often the cache refreshes, never on page views.
#What a PHP job costs
Sorsa API charges per request, whatever the endpoint returns: timelines and search cost from $0.07 per 1,000 tweets, batches from $0.014, and failed requests are never charged. The official X API charges per object instead: $5.00 per 1,000 posts, and from November 22, 2026 up to $15.00 per 1,000 when the code expands each post's author, as most TwitterOAuth searches do.
Official X API
$5,000
for 1M tweets
$0.005 per post
Sorsa API
from$70
for 1M tweets
from $0.07 per 1,000
The author's profile comes inside every tweet
The rate per 1,000 requests runs from $4.90 on Starter to $1.40 on Hyperscale, and each plan applies it to every endpoint alike. Checking what is left costs nothing: GET /key-usage-info (opens in a new tab) never counts against your requests.
$usage = sorsa('GET', '/key-usage-info');
echo $usage['remaining_requests'], ' left until ', $usage['valid_until'], "\n";
#Moving TwitterOAuth code to Sorsa API
Most TwitterOAuth reads map to one Sorsa API endpoint, and the code drops its expansions and field lists, because every tweet already carries its author and counts. The table pairs common v2 paths with their endpoints; to move a whole codebase, the guide to migrate from the Twitter/X API maps every v2 endpoint and response field.
| TwitterOAuth call | Sorsa API endpoint | What changes |
|---|---|---|
get('tweets/search/recent') | POST /search-tweets | One endpoint for recent and archive search, back to 2006 |
get("users/$id/tweets") | POST /user-tweets | A username is enough, and there is no 3,200-post cap |
get('tweets') with ids | POST /tweet-info-bulk | 100 IDs or links per request |
get('users/by') with usernames | GET /info-batch | 100 usernames per request |
get("users/$id/followers") | GET /followers | 200 followers per page |
post('tweets') | none | Read-only: post through the official X API |
get('tweets/search/recent')- Sorsa API endpoint
POST /search-tweets- What changes
- One endpoint for recent and archive search, back to 2006
get("users/$id/tweets")- Sorsa API endpoint
POST /user-tweets- What changes
- A username is enough, and there is no 3,200-post cap
get('tweets')withids- Sorsa API endpoint
POST /tweet-info-bulk- What changes
- 100 IDs or links per request
get('users/by')withusernames- Sorsa API endpoint
GET /info-batch- What changes
- 100 usernames per request
get("users/$id/followers")- Sorsa API endpoint
GET /followers- What changes
- 200 followers per page
post('tweets')- Sorsa API endpoint
- none
- What changes
- Read-only: post through the official X API
Field names change as well: text becomes full_text, public_metrics->like_count becomes likes_count, author_id with includes->users becomes the user array, and meta->next_token becomes next_cursor. TwitterOAuth returns objects, while json_decode($body, true) gives the arrays this guide uses. The user timeline also reaches past the newest 3,200 posts, where the official timeline stops (opens in a new tab).
#Send your first PHP request today
Reading X data in PHP takes the cURL extension, one key and four short functions: sorsa() for retries, pages() for cursors, array_chunk() with the batch endpoints for 100 items per request, and parallel() for 19 requests a second. Start with Sorsa API, the Twitter API built for reading public X data at scale: 20 tweets per timeline or search request from $0.07 per 1,000, 100 per batch from $0.014, and 100 free requests that need no card and never expire. The same calls in other languages are in the guides to the Twitter API in Python and the Twitter API in Node.js.
#FAQ
Which Twitter API should I use with PHP?
For reading X data from PHP, use Sorsa API: one key in an ApiKey header instead of OAuth, plain JSON for json_decode, timelines and search from $0.07 per 1,000 tweets and batches from $0.014, 20 requests per second on every plan, search back to 2006, and no X login, so your account is never at risk. To post as well, pair it with the official X API.
Is there a free Twitter API for PHP?
The official X API has no free tier for new developers: TwitterOAuth is free, but every read spends prepaid credits at $0.005 per post. Sorsa API starts every account with 100 free requests that never expire and need no card. From PHP they fetch 2,000 tweets through timelines or search, or up to 10K tweets in batches of 100. The full picture is in is the Twitter API free.
Does Twitter (X) use PHP?
No. Twitter launched on Ruby on Rails and from 2010 moved its back end to the JVM, with services in Scala and Java, as Twitter's engineering blog (opens in a new tab) describes. The recommendation code X published in 2023 and again in January 2026 is mostly Scala, Java, Rust and Python, with no PHP at all. PHP works well as a client all the same: X data reaches a PHP script as JSON over HTTPS.
Can I post tweets from PHP with Sorsa API?
No. Sorsa API is read-only by design: it reads public posts, profiles and followers and never posts, likes, follows or sends messages, so a key can't act on any X account. To post from PHP, use the official X API with OAuth 1.0a user tokens, such as TwitterOAuth's post('tweets', ...) at $0.015 a post, and keep Sorsa API for the reads.
Can I use Sorsa API in WordPress?
Yes. WordPress's own HTTP functions send the same request: wp_remote_post() with the ApiKey header and a JSON body, then wp_remote_retrieve_body() and json_decode(). Keep the result with set_transient() for a few minutes, so page views never turn into requests, and store the key in wp-config.php or an environment variable, never in a script the browser loads.
Is there a Composer package for Sorsa API?
No package is needed: Sorsa API is plain REST with JSON, so the cURL extension, Guzzle or Laravel's Http client covers all 45 endpoints, and the functions in this guide fit in about 100 lines. The OpenAPI reference at api.sorsa.io/v3/swagger.json lists every endpoint, so a generator such as openapi-generator can build a typed PHP client if you want one.
#How we checked
We wrote every request in this guide from Sorsa API's live OpenAPI reference (45 endpoints) and its docs on rate limits and pagination, read on November 2, 2026. We had no Sorsa API key for this pass, so the response shown uses demo values, and we ran each PHP block on PHP 8.4.27 with Guzzle 8.3.1 and Laravel's illuminate/http 13 against a local mock that answers in the documented shapes and returns 429 past 20 requests a second: the first requests, retries, both cursor loops, batches of 100, the query strings, the Laravel macro, and parallel(), which sent 100 batch requests at 19 a second with at most one 429 per run, always recovered by its retry. The same day we read X's price table, tools page, timeline docs and expansions notice, and the TwitterOAuth, Guzzle and Laravel releases on Packagist.
Sources
- X API pay-per-usage pricing (docs.x.com) (opens in a new tab)
- Tools and libraries: official SDKs and community PHP clients (docs.x.com) (opens in a new tab)
- X API PPU: expansions update (X Developers, October 23, 2026) (opens in a new tab)
- Getting Access (docs.x.com) (opens in a new tab)
- Timelines: up to 3,200 most recent posts (docs.x.com) (opens in a new tab)
- TwitterOAuth documentation (opens in a new tab)
- abraham/twitteroauth on Packagist (opens in a new tab)
- Laravel HTTP client documentation (opens in a new tab)
- New Tweets per second record, and how (Twitter Engineering, 2013) (opens in a new tab)
- Sorsa API reference, OpenAPI
- Sorsa API rate limits (opens in a new tab)
- Sorsa API pagination (opens in a new tab)