RSS to X (Twitter) PHP Bot. Most sites already broadcast updates via RSS, but posting to X (Twitter) is still a manual chore. In this mini build, we ship a self-hosted PHP bot that reads your RSS feed and publishes new items to X using the official API—no plugins, no monthly fees. It’s the sibling of our Instagram bot: simple cURL requests, a little formatting, and a cron job.
What it does
-
Reads RSS and picks the latest items (with a per-run limit).
-
Builds the post text (title + link, truncated to fit X limits).
-
Calls X API with a bearer token to publish.
-
De-dupes items (so you don’t double-post) and avoids overlapping runs.
Why this approach
-
Total control (self-hosted), zero subscriptions.
-
Works with any stack (plain PHP + cURL).
-
Easy to extend: hashtags, UTM links, schedules, language filters…
What you’ll need (no secrets shown)
-
An X developer account and a Bearer token for posting (app in write mode).
-
A public RSS feed URL.
-
PHP with cURL.
-
(Optional) a tiny file/DB store to remember posted GUIDs.
Posting flow (high-level):
-
GET RSS → parse items
-
For each unseen item: build
status = "{title} {short_link}"
-
POST to X’s API with
Authorization: Bearer …
-
Store GUID hash to avoid duplicates
Code snippet (redacted & minimal)
A safe skeleton you can paste (replace placeholders in your private copy; don’t publish real keys):
<!--?php // rss-to-x.php (key-free / blog-safe version) // Self-hosted PHP: reads RSS, posts one item to X (Twitter) via OAuth 1.0a user context. // Requires: PHP cURL, a writable /data folder, and .env.php for secrets. date_default_timezone_set('Europe/Istanbul'); // Load secrets & settings from local file (NOT committed to git) $config = require __DIR__.'/.env.php'; // Ensure state folder exists @mkdir(__DIR__.'/data', 0777, true); /* ---------- Small helpers ---------- */ // HMAC-SHA1 + base64 for OAuth 1.0a signature function hmac_sha1($data, $key){ return base64_encode(hash_hmac('sha1', $data, $key, true)); } // Build OAuth Authorization header function build_oauth_header($params){ ksort($params); $values = []; foreach($params as $k=>$v){ $values[] = $k.'="'.rawurlencode($v).'"'; }<br ?--> return ‘OAuth ‘.implode(‘, ‘, $values);
}
// Minimal JSON POST with extra headers
function http_post_json($url, $headers, $json){
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => array_merge([‘Content-Type: application/json’], $headers),
CURLOPT_POSTFIELDS => json_encode($json, JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => ‘rss-to-x-bot/1.0′
]);
$res = curl_exec($ch);
if ($res === false) throw new Exception(curl_error($ch));
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$code, $res];
}
// OAuth 1.0a signing (body params not included for v2 JSON endpoints)
function sign_oauth1($method, $url, $params, $consumerKey, $consumerSecret, $token, $tokenSecret){
$baseParams = $params;
ksort($baseParams);
$baseStr = strtoupper($method).’&’.rawurlencode($url).’&’.rawurlencode(http_build_query($baseParams, ”, ‘&’, PHP_QUERY_RFC3986));
$signKey = rawurlencode($consumerSecret).’&’.rawurlencode($tokenSecret);
$params[‘oauth_signature’] = hmac_sha1($baseStr, $signKey);
return $params;
}
/* ———- 1) Read RSS ———- */
$feed = @simplexml_load_file($config[‘FEED_URL’]);
if (!$feed) { die(“RSS error”); }
$items = [];
foreach ($feed->channel->item as $it){
$items[] = [
‘guid’ => (string)($it->guid ?? $it->link),
‘title’ => trim((string)$it->title),
‘link’ => trim((string)$it->link),
‘date’ => strtotime((string)$it->pubDate ?: ‘now’),
];
}
// Oldest first, so we post in chronological order
usort($items, fn($a,$b) => $a[‘date’] <=> $b[‘date’]);
/* ———- 2) Load state (dedupe) ———- */
$stateFile = $config[‘STATE_FILE’]; // e.g., __DIR__.’/data/posted.json’
$state = file_exists($stateFile) ? json_decode(file_get_contents($stateFile), true) : [];
if (!is_array($state)) $state = [];
/* ———- 3) Find the next unseen item & post ———- */
foreach ($items as $it){
if (isset($state[$it[‘guid’]])) continue;
// 3a) Compose status (<= 280 chars incl. URL & hashtags)
$hashtags = [‘#news’, ‘#automation’, ‘#php’]; // <– replace with your own
$max = 280;
$suffix = ‘ ‘ . $it[‘link’] . ‘ ‘ . implode(‘ ‘, $hashtags);
$base = $it[‘title’];
$maxTitle = $max – mb_strlen($suffix);
if ($maxTitle < 1) $maxTitle = 1; // safety if (mb_strlen($base) > $maxTitle) $base = mb_substr($base, 0, $maxTitle – 1) . ‘…’;
$text = $base . $suffix;
// 3b) Sign OAuth 1.0a and POST to X API v2 (Tweets)
// Note: api.twitter.com is widely used; api.x.com also works in some regions.
$endpoint = ‘https://api.twitter.com/2/tweets’;
$oauth = [
‘oauth_consumer_key’ => $config[‘API_KEY’],
‘oauth_nonce’ => bin2hex(random_bytes(8)),
‘oauth_signature_method’ => ‘HMAC-SHA1’,
‘oauth_timestamp’ => time(),
‘oauth_token’ => $config[‘ACCESS_TOKEN’],
‘oauth_version’ => ‘1.0’,
];
$signed = sign_oauth1(‘POST’, $endpoint, $oauth,
$config[‘API_KEY’], $config[‘API_KEY_SECRET’],
$config[‘ACCESS_TOKEN’], $config[‘ACCESS_TOKEN_SECRET’]
);
[$code, $res] = http_post_json($endpoint, [
‘Authorization: ‘ . build_oauth_header($signed)
], [‘text’ => $text]);
if ($code >= 200 && $code < 300) { $state[$it[‘guid’]] = [‘tweeted_at’ => date(‘c’), ‘text’ => $text];
@file_put_contents($stateFile, json_encode($state, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE));
echo “Tweeted: “.$text.”\n”;
} else {
echo “Error ($code): “.$res.”\n”;
}
// Post only one item per run (simplest). Increase if you want a batch.
break;
}
.evn.php code
<!--?php // .env.php (do not commit) return [ 'FEED_URL' => 'https://example.com/feed',<br ?--> ‘STATE_FILE’ => __DIR__.’/data/posted.json’,
// X OAuth 1.0a user context (WRITE permission)
‘API_KEY’ => ‘YOUR_API_KEY’,
‘API_KEY_SECRET’ => ‘YOUR_API_KEY_SECRET’,
‘ACCESS_TOKEN’ => ‘YOUR_ACCESS_TOKEN’,
‘ACCESS_TOKEN_SECRET’ => ‘YOUR_ACCESS_TOKEN_SECRET’,
];
Cron
*/30 * * * * /usr/bin/php /path/to/rss-to-x.php >/dev/null 2>&1
File Tree
/your-project
├─ rss-to-x.php
├─ .env.php
└─ data/
FAQ (short)
-
Why not Zapier/IFTTT? Self-hosted = full control, no per-post fees or rate surprises.
-
How do I avoid duplicates? Store the RSS
guid
hash after a successful post. -
How to schedule? Cron:
*/30 * * * * curl -fsS 'https://yourdomain.com/rss-to-x.php' >/dev/null
-
Can I include images? Yes, but you’ll need the media upload endpoints; this snippet is text-only to keep it simple.
Related Links :
Github : Self-hosted PHP bot: RSS → X (Twitter)