PHP
Twitter API PHP client
How do I call the Twitter API from PHP?
The PHP client for TwitterAPIs is the built-in curl extension and json_decode, both shipped with most PHP builds. Authentication is a single static header, so there is no credential machinery to build and no Composer package to add. One method on a small class covers every read endpoint, with a typed array result and an explicit timeout you control.
No dependency. curl ships with most PHP builds.
<?php
const BASE = "https://api.twitterapis.com";
function twitterapis_get(string $path, array $params = []): array {
$url = BASE . "/twitter/" . $path . "?" . http_build_query($params);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("TWITTERAPIS_KEY")],
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("twitterapis: status $status");
}
return json_decode($body, true);
}
$profile = twitterapis_get("user/info", ["username" => "naval"]);
echo $profile["user"]["id"], " ", $profile["user"]["followers_count"], PHP_EOL;A reusable client class
Wrapping the key and a timeout in a class gives you one place to configure both, one place to add retry logic later, and a single get() method every call site shares instead of repeating the curl boilerplate.
final class TwitterApisClient
{
private string $key;
private int $timeout;
public function __construct(string $key, int $timeout = 30)
{
$this->key = $key;
$this->timeout = $timeout;
}
public function get(string $path, array $params = []): array
{
$url = "https://api.twitterapis.com/twitter/" . $path
. "?" . http_build_query($params);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $this->key],
CURLOPT_TIMEOUT => $this->timeout,
]);
$body = curl_exec($ch);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("twitterapis: transport error: $err");
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("twitterapis: status $status: $body");
}
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
}Walking a cursor-paginated endpoint
A do-while loop reads next_cursor from each page and feeds it back as the next request's cursor parameter. It stops on whichever comes first, a page whose collection is empty or a cursor that has come back empty, because the two endpoint families fail in opposite directions and either guard alone can loop. The callback lets the caller abort early, which matters when each page is a separate billed call.
How many times that loop runs is the number worth knowing before you start it, because billing is per call rather than per record. Across 396,817 successful tweet-returning read calls on our own billing logs, 13 to 17 August 2026, a timeline read returned 18.78 records on average and a search read returned 7.62, with 29.5% of search calls returning nothing at all and still counting. Size a job from the figure for the endpoint you are actually calling, not from the page size you requested.
/**
* Walks every page of a cursor-paginated endpoint, calling $fn once per page.
* Each page is one billed call, so the caller controls the loop by having
* $fn return false to stop early.
*/
function followers(TwitterApisClient $client, string $username, callable $fn): void
{
$cursor = null;
$calls = 0;
$maxCalls = 500;
$seen = [];
do {
$params = ["username" => $username];
if ($cursor !== null) {
$params["cursor"] = $cursor;
}
$page = $client->get("user/followers", $params);
if (++$calls >= $maxCalls) {
throw new RuntimeException("hit the $maxCalls-call cap, resume from $cursor");
}
// BOTH terminators, and the empty check runs BEFORE the callback so a
// caller is never handed the trailing empty page. user/followers keeps
// returning a non-empty cursor after the last real page, so the cursor
// check alone never fires.
if (empty($page["users"])) {
return;
}
if ($fn($page) === false) {
return;
}
$cursor = $page["next_cursor"] ?? "";
if ($cursor !== "" && isset($seen[$cursor])) { // stuck cursor AND any cycle
throw new RuntimeException("cursor stopped advancing at $cursor");
}
$seen[$cursor] = true;
} while ($cursor !== "");
}Where to go next
The same client shape in Python and Go, or all six languages on the language clients page. The REST API reference covers the paging model and the status codes this client branches on, and the rate limits page covers what concurrency you can actually run.
Frequently Asked Questions
There is no Composer package to install here, and the client above shows why one would add little. curl handles the request, json_decode handles the response, and the only credential work is setting a single header. That is the entire surface a package would wrap, so writing it yourself gives you a client you can read in one file and change without waiting on a maintainer.
They are two different failure modes and curl reports them differently. curl_exec returns false on a transport failure (DNS, TLS, connection reset, timeout), which curl_error explains. A non-200 HTTP status is a successful transport with an application-level rejection, read from curl_getinfo's CURLINFO_RESPONSE_CODE. Check both, in that order, or a transport failure gets misread as a bad response body.
Yes, with curl_multi_init, which runs several handles on one event loop without needing threads or an async framework. There is one ceiling to design around, 600 requests a minute and 20 concurrent per key, so cap the multi-handle's running count at or under 20 rather than adding every request at once.
Set CURLOPT_TIMEOUT on the handle, in seconds, before calling curl_exec. It bounds the whole request including DNS and TLS setup. If you need to bound just the connect phase separately, CURLOPT_CONNECTTIMEOUT does that; most single-region API calls only need the one overall timeout.
Read next_cursor from each response and pass it as the cursor parameter on the next request, stopping when it comes back empty. The helper on this page uses a do-while loop with a callback so the caller can stop early, which matters when each page is a separate billed call and a large account has a lot of them.
$0.0008 for a standard read, the same everywhere, billed per call rather than per plan. New accounts start with $0.50 of credit, about 625 read calls and no card, which is enough to run a real pagination loop before you commit to a budget. The client makes exactly one request per get(), so your bill is the number of times your code calls it. A pagination loop or an unbounded retry is where a count grows without anybody deciding it should.