~ / guides / How to Scrape Twitter (X) With PHP (2026)

How to Scrape Twitter (X) With PHP (2026)

KM
Kit Mason
X data engineer · about the author
the short version
  • The language is not the hard part, PHP hits the same wall. A plain Guzzle or cURL GET to x.com/<user> returns a ~607 KB login shell with zero tweet selectors, because X loads the timeline with JavaScript afterward.
  • Two PHP routes still return data: drive a real browser with Symfony Panther (WebDriver, resilient but heavy), or skip the browser and call a JSON source with Guzzle or curl plus json_decode.
  • The old no-token PHP scrapers on Packagist mostly broke when X walled guest access in 2023, and the free Goutte HTML scraper was archived that same year.
  • For a feed that does not break every few weeks I call a managed scraper API from PHP: one Guzzle GET returns parsed JSON, no doc_id chasing and no proxy pool.

I get the same PHP question two ways: is there a Composer package that scrapes Twitter, and can I just cURL a profile and read the tweets off it? The honest answer to how to scrape Twitter (X) with PHP is that the language is not the hard part. X serves its timeline through a JavaScript front end backed by an internal GraphQL API, guarded by guest tokens and rotating query identifiers, and a PHP script hits that wall the same way Python or Node does.

This guide stays in PHP. I show the routes that still return X data in 2026, a real browser driven by Symfony Panther and plain HTTP with Guzzle or cURL, the exact code I ran against live X pages in July 2026, where each approach breaks, and the single managed call I reach for when I do not want to babysit a scraper.

Can you still scrape Twitter (X) with PHP in 2026?

You can still scrape Twitter (X) with PHP in 2026, but not the way the old tutorials show, because the profile page HTML no longer contains the tweets. I can prove it in one request. When I fetched https://x.com/nasa logged out this month with Guzzle, the server returned HTTP 200 and about 607 KB of HTML, and the raw markup held zero tweet nodes.

<?php
// composer require guzzlehttp/guzzle
require "vendor/autoload.php";

use GuzzleHttp\Client;

$ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ".
      "(KHTML, like Gecko) Chrome/124.0 Safari/537.36";

$client = new Client();
$res = $client->get("https://x.com/nasa", ["headers" => ["User-Agent" => $ua]]);

$html = (string) $res->getBody();
echo $res->getStatusCode(), "\n";                       // -> 200
echo strlen($html), "\n";                               // -> ~607000 bytes
echo substr_count($html, 'data-testid="tweet"'), "\n";  // -> 0

The status looks like success and the body is large, but a parser finds nothing, because the tweets were never in that response. X’s React app fetches them afterward from GraphQL, so a Guzzle or cURL GET only ever sees the shell. That splits PHP scraping into two honest options: run a browser that executes the JavaScript, or go straight to a JSON source. Both need the right tool, which is the next question.

What do you need to scrape Twitter (X) with PHP?

To scrape Twitter (X) with PHP you need one of two toolsets: a browser automation library that runs X’s JavaScript, or an HTTP client that calls a JSON source directly. The choice follows from the wall above. If you must render the page, you need a real browser. If you can reach the data as JSON, a lightweight HTTP client is enough.

ToolWhat it doesWhen you use it for X
GuzzleHTTP client installed via ComposerCalling a JSON endpoint or a scraper API
cURL (ext-curl)Built-in HTTP, no dependenciesThe same calls when you cannot add packages
Symfony PantherDrives real Chrome or Firefox over WebDriverRendering X so its JavaScript executes
php-webdriverSelenium bindings Panther sits onLower-level browser control
DomCrawler + CssSelectorParse HTML with CSS or XPath selectorsReading a rendered DOM
Goutte / HttpBrowserHTML-only crawler, no JavaScriptNot for X, it returns the empty shell

The last row is the trap most PHP tutorials fall into. Goutte was the go-to no-browser scraper for years, but its maintainer archived the project in 2023 and folded it into Symfony’s BrowserKit, so the migration note now tells you to swap Goutte\Client for Symfony\Component\BrowserKit\HttpBrowser. Neither runs JavaScript, so pointing either at an X profile returns the same shell my Guzzle probe got. That leaves the browser route, and in PHP that means Panther.

How do you scrape Twitter (X) with Symfony Panther?

You scrape Twitter (X) with Symfony Panther by launching a real Chrome or Firefox session, opening the X URL, letting the JavaScript render, then reading the loaded DOM. Panther drives native browsers over the W3C WebDriver protocol and is built on top of php-webdriver, so the page sees a genuine browser that executes X’s scripts rather than a bare HTTP client. It can also wait for elements to appear and run custom JavaScript in the page, which is what a React app like X needs.

<?php
// composer require symfony/panther
require "vendor/autoload.php";

use Symfony\Component\Panther\Client;

$client = Client::createChromeClient();
$client->request("GET", "https://x.com/nasa");
sleep(4); // let the GraphQL timeline call resolve

for ($i = 0; $i < 3; $i++) {          // scroll to load more tweets
    $client->executeScript("window.scrollBy(0, 2000);");
    sleep(2);
}

$crawler = $client->getCrawler();
$crawler->filter('article [data-testid="tweetText"]')->each(function ($node) {
    echo $node->text(), "\n";
});

$client->quit();

That code runs, but on a clean machine X often shows a login or challenge wall before the timeline loads, because a default ChromeDriver session carries a detectable WebDriver fingerprint. The X Developer Policy states plainly that scraping and browser automation are not permitted, and the web app enforces parts of that with bot detection. Two things reduce the challenge rate: a stealth-patched driver that hides the automation flags, and a residential proxy so the request does not come from a flagged datacenter range.

Even clean, the data-testid selectors are fragile. X reshuffles its markup often, so a Panther scraper that reads the DOM needs patching whenever the layout shifts. I cover the proxy and fingerprint side in depth in how to scrape Twitter without getting blocked. If you only need one tweet and not a whole timeline, you can skip the browser entirely, which is cheaper and steadier.

How do you scrape a single tweet in PHP without a browser?

You scrape a single tweet in PHP without a browser by requesting X’s public syndication endpoint with Guzzle or cURL, which returns one tweet as JSON by id. This is the same CDN that powers embedded tweets, so it answers without a login cookie, a guest token, or a proxy. I ran it this month and it returned Jack Dorsey’s first tweet by its id.

<?php
// composer require guzzlehttp/guzzle
require "vendor/autoload.php";

use GuzzleHttp\Client;

$client = new Client();
$res = $client->get("https://cdn.syndication.twimg.com/tweet-result", [
    "query" => ["id" => "20", "token" => "x"],
]);

$tweet = json_decode((string) $res->getBody(), true);
echo $tweet["user"]["screen_name"], " | ", $tweet["text"], "\n";
// -> jack | just setting up my twttr

The same JSON carries the author, timestamp, and a live favorite_count, so it is genuinely useful data, not a scrape of rendered HTML. If you cannot add Composer packages, the built-in cURL extension does the identical call with no dependencies. Guzzle just wraps this more cleanly.

<?php
$url = "https://cdn.syndication.twimg.com/tweet-result?id=20&token=x";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);
curl_close($ch);

$tweet = json_decode($body, true);
echo $tweet["text"], "\n"; // -> just setting up my twttr

The catch is scope: this path covers single tweets by id and nothing else. There is no syndication route for a profile timeline, a keyword search, or a follower list, which is exactly what most scraping jobs need. The moment you want more than one tweet at a time, you are back to the guest-token GraphQL route, and that is where PHP scrapers start breaking.

Why do hand-rolled PHP Twitter scrapers keep breaking?

Hand-rolled PHP Twitter scrapers keep breaking because X changes the things your code depends on every few weeks: it expires guest tokens, rotates the GraphQL doc_id query identifiers, and shifts rate limits. None of these are documented for scrapers, so you find out when a working job starts returning empty payloads or rate-limit errors, with no change on your side.

The Packagist history shows the pattern. Most of the no-token PHP scrapers that once read twitter.com were built on the unauthenticated frontend API X restricted in 2023, and they returned login redirects afterward. The free HTML crawler everyone reached for, Goutte, was archived the same year. The libraries that survive are the ones that expect you to supply authentication and keep chasing X’s backend changes yourself.

The official escape hatch is metered, not free. X’s Developer Platform moved to pay-per-use pricing, and the X API pricing documentation lists post reads at $0.005 per resource, with reads of your own data at $0.001 per resource, deduplicated within a 24-hour window. A job that reads even a few hundred thousand posts a month runs into real per-resource billing. There is a legal layer too: public posts are visible to anyone, but X’s Terms of Service restrict automated collection, which I unpack in is scraping Twitter legal. Between the maintenance and the metering, a managed call is usually the cheaper trade for an ongoing feed.

How do you scrape Twitter (X) with PHP without managing tokens or browsers?

You scrape Twitter (X) with PHP without managing tokens or browsers by calling a scraper API that takes a username, tweet id, or search query and returns parsed JSON, while the provider handles the guest tokens, doc_id rotation, and proxies. You send one HTTP request from PHP and get structured data back, with no Chrome process and nothing to patch when X changes.

This is the request shape I use. It is a single Guzzle GET with the target and an api_key, the same pattern the browser routes above avoid.

<?php
// composer require guzzlehttp/guzzle
require "vendor/autoload.php";

use GuzzleHttp\Client;

$client = new Client(["base_uri" => "https://chocodata.com"]);
$res = $client->get("/api/v1/twitter/profile", [
    "query" => [
        "username" => "nasa",
        "api_key"  => getenv("CHOCO_API_KEY"),
    ],
]);

$profile = json_decode((string) $res->getBody(), true)["data"];
echo $profile["name"], " - ", $profile["followers_count"], " followers\n";

When I probed the ChocoData surface this month, the request authenticated against api.chocodata.com and a deliberately invalid key came back as 401 {"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}, which confirms the auth step is real and the endpoint is serving. With a valid key it returns the profile JSON with no login cookie and no proxy pool on my side. The bare curl version is the same call from any shell:

curl "https://chocodata.com/api/v1/twitter/profile?username=nasa&api_key=$CHOCO_API_KEY"

To collect an account’s tweets instead of the profile, you change the resource and read the list the same way, then loop or write it to a database:

<?php
$res = $client->get("/api/v1/twitter/tweets", [
    "query" => [
        "username" => "nasa",
        "count"    => 50,
        "api_key"  => getenv("CHOCO_API_KEY"),
    ],
]);

$tweets = json_decode((string) $res->getBody(), true)["data"]["tweets"];
foreach ($tweets as $t) {
    echo $t["created_at"], " - ", $t["favorite_count"], " - ", $t["text"], "\n";
}

Swapping the resource changes what you collect, and each X object maps to its own endpoint on the same base and key:

You wantEndpointExample call
A profile / accountProfile scraper/twitter/profile?username=nasa
Tweets / postsTweet scraper/twitter/tweets?username=nasa
Search / hashtag / trendsSearch scraper/twitter/search?query=<terms>
Followers / followingFollower scraper/twitter/followers?username=nasa
Images / videoMedia scraper/twitter/media?username=nasa

Because it is JSON over HTTP, the same call works from Guzzle, the cURL extension, or any framework’s HTTP layer, and the parsing is one json_decode. The free tier covers 1,000 requests, enough to wire the calls above into a Laravel or Symfony job before you commit to anything. That leaves the question of when each PHP route is actually the right call.

Which PHP method should you choose to scrape Twitter?

The right PHP method to scrape Twitter depends on how much data you need, whether you can maintain a browser scraper, and how much engineering time you want to spend chasing X’s changes. Here is the summary I give people who ask.

If you need…UseWhy
One tweet by id, no dependenciesGuzzle or cURL to the syndication CDNFree JSON, but single tweets only
To render a profile or search yourselfSymfony Panther (real browser)Resilient rendering, but heavy and detectable
A feed of profiles, tweets, or search at scaleManaged scraper API (ChocoData)Tokens, proxies, and parsing are server-side
Fully sanctioned access, budget for itOfficial X API (pay per use)Metered per post read, no block risk

For a one-off pull of a handful of tweets, the syndication call or a quick Panther script in your own stack is fine. For a feed you need to keep alive, moving the token refresh and doc_id chase to a provider is the cheaper trade once you price in the patching. If you want to compare the managed options head to head before you commit, I rank them in my best Twitter scrapers in 2026 roundup.

FAQ

What is the best PHP library to scrape Twitter (X)?

It depends on whether you need a browser. For rendering X so its JavaScript runs, Symfony Panther is the strongest PHP option because it drives real Chrome or Firefox over the WebDriver protocol. For everything else a plain HTTP client is simpler: Guzzle (or the built-in cURL extension) to call a JSON endpoint, with DomCrawler if you still need to parse HTML. The old no-token Packagist scrapers largely stopped returning tweets after X walled guest access, so I do not build anything new on them.

Can you scrape Twitter with PHP without a browser?

Yes, but not by parsing the profile page, because that HTML no longer contains the tweets. The browser-free routes that work in PHP are: request X's public syndication endpoint for a single tweet by id with Guzzle or cURL, or send a username, tweet id, or search query to a managed scraper API and read the JSON it returns. Both are plain HTTP calls, so a few lines of Guzzle plus json_decode is the whole client.

Does Symfony Panther get blocked by X?

A default Panther session is detectable on X because it drives Chrome through ChromeDriver, which carries a WebDriver fingerprint, and a datacenter IP gets challenged fast. People reduce the challenge rate with a stealth-patched driver, residential proxies, and human-like pacing, but X also rotates its GraphQL query identifiers, so even a working Panther scraper needs upkeep. The X Developer Policy prohibits scraping and browser automation outright.

Why do my PHP cURL requests to Twitter return no tweets?

Your cURL request returns no tweets because X ships an almost empty HTML shell and fetches the timeline afterward with JavaScript from its internal GraphQL API. A raw cURL or Guzzle GET only ever sees that shell, so substr_count($html, 'data-testid="tweet"') comes back 0 even on a clean 200. You need a real browser to run the scripts, the guest-token GraphQL route with proxies, or a scraper API that does both for you.

Is it legal to scrape Twitter (X) with PHP?

The language does not change the legal picture. Scraping publicly visible X pages is generally treated as lawful in the United States, while X's Terms of Service and Developer Policy restrict automated access without permission, so the contract layer is separate from the public-data question. I walk through the case law, robots.txt, and the terms in my is scraping Twitter legal guide.

KM
Kit Mason
I've built X data pipelines for years. On twitterscraperapi.com I run X scraping methods against live pages and publish what actually holds up.