X (Twitter) API v2 client for Laravel.
Plume wraps the entire X API v2 behind a clean Laravel facade. Typed DTOs, automatic pagination, user-scoped operations, OAuth token refresh, rate-limit handling, test fakes with semantic assertions, 41 artisan commands, and 15 AI tools for the Laravel AI SDK.
composer require jkudish/plume
php artisan vendor:publish --tag=x-configAdd to .env:
X_BEARER_TOKEN=your-bearer-token
X_CLIENT_ID=your-client-id
X_CLIENT_SECRET=your-client-secretuse Plume\Facades\X;
// Post a tweet
$post = X::createPost('Hello from Plume!');
// Search recent tweets
$results = X::searchRecent('laravel');
foreach ($results->data as $post) {
echo "{$post->text}\n";
}
// Get your profile
$me = X::me();
echo $me->publicMetrics->followersCount;Most X API libraries for PHP are either stuck on API v1.1, aren't Laravel-native, or lack the DX features that make building with the API pleasant.
| Feature | Plume | abraham/twitteroauth | noweh/twitter-api-v2-php |
|---|---|---|---|
| X API v2 | Full coverage | Partial | Partial |
| Laravel facades | Yes | No | No |
| Typed DTOs | Active Record methods | Arrays | Arrays |
| Test fakes | Semantic assertions | No | No |
| OAuth auto-refresh | Built-in | Manual | Manual |
| Artisan commands | 41 commands | No | No |
| AI tools (Laravel AI SDK) | 15 tools | No | No |
| Pagination | Automatic | Manual | Manual |
| Rate-limit handling | Structured exceptions with retry timing | Manual | Manual |
Plume is designed to be the canonical X API package for Laravel: typed, testable, and ready for both CLI and AI agent use.
Every endpoint in the X API v2:
| Domain | Methods |
|---|---|
| Posts | createPost, deletePost, getPost, getPosts, hideReply, unhideReply |
| Timelines | userTimeline, mentionsTimeline, homeTimeline |
| Search | searchRecent, searchAll, countRecent, countAll |
| Users | getUser, getUsers, getUserByUsername, getUsersByUsernames, me, searchUsers |
| Likes | like, unlike, likingUsers, likedTweets |
| Retweets | retweet, undoRetweet, retweetedBy, quoteTweets |
| Bookmarks | bookmark, removeBookmark, bookmarks |
| Follows | follow, unfollow, followers, following |
| Blocks | block, unblock, blockedUsers |
| Mutes | mute, unmute, mutedUsers |
| Lists | createList, updateList, deleteList, getList, ownedLists, listTweets, listMembers, listFollowers, addListMember, removeListMember, followList, unfollowList, pinList, unpinList |
| Media | uploadMedia, initChunkedUpload, appendChunk, finalizeUpload, uploadStatus, setMediaMetadata |
All methods are fully typed with enums for field selection (TweetField, UserField, Expansion, etc.) and return typed DTOs (Post, User, XList, PaginatedResult).
$post = X::getPost('123', tweetFields: [TweetField::PublicMetrics]);
echo $post->publicMetrics->likeCount;
// DTOs carry action methods
$post->like('user-id');
$post->reply('Nice post!');
$post->bookmark('user-id');
$post->delete();$page = X::userTimeline('user-id', maxResults: 100);
while ($page !== null) {
foreach ($page->data as $post) {
process($post);
}
$page = $page->nextPage();
}ScopedXClient operates on behalf of a specific user. No more passing $userId to every call.
// From credentials array or a model implementing HasXCredentials
$client = X::forUser($user);
// Inject user ID directly to skip the /me API call
$client = X::forUser($credentials)->withUser('12345');
// Or pass a User DTO
$client = X::forUser($credentials)->withUser($userDto);
// All calls auto-resolve the user ID
$client->like('tweet-id');
$client->bookmark('tweet-id');
$client->followers();
$client->userTimeline(maxResults: 20);Implement HasXCredentials on your User model:
use Plume\Contracts\HasXCredentials;
class User extends Authenticatable implements HasXCredentials
{
public function toXCredentials(): array
{
return [
'access_token' => $this->x_access_token,
'refresh_token' => $this->x_refresh_token,
'expires_at' => $this->x_token_expires_at,
];
}
}Plume handles token refresh automatically on 401 responses. Persist refreshed tokens with a callback:
// In AppServiceProvider::register()
$this->app->bind('x.token_refreshed', fn () => function (array $credentials) {
auth()->user()->update([
'x_access_token' => $credentials['access_token'],
'x_refresh_token' => $credentials['refresh_token'],
'x_token_expires_at' => $credentials['expires_at'],
]);
});Plume throws a structured RateLimitException on 429 responses with built-in retry timing:
use Plume\Exceptions\RateLimitException;
try {
$results = X::searchRecent('laravel');
} catch (RateLimitException $e) {
$seconds = $e->retryAfterSeconds(); // seconds until rate limit resets
$timestamp = $e->resetTimestamp; // unix timestamp of reset
sleep($seconds);
// retry...
}All exceptions include rate-limit headers (x-rate-limit-limit, x-rate-limit-remaining, x-rate-limit-reset) when available.
// Simple upload
$media = X::uploadMedia('/path/to/image.jpg', 'image/jpeg');
X::createPost('Check this out!', [
'media' => ['media_ids' => [$media['media_id']]],
]);
// Chunked upload for large files
$init = X::initChunkedUpload($totalBytes, 'video/mp4', 'tweet_video');
X::appendChunk($init['media_id'], 0, $chunkData);
X::finalizeUpload($init['media_id']);X::fake() swaps the client with an in-memory fake that records all calls:
use Plume\Facades\X;
it('creates a post', function () {
$fake = X::fake();
X::createPost('Hello from tests!');
$fake->assertPostCreated('Hello');
$fake->assertCalledTimes('createPost', 1);
});
it('tracks interactions', function () {
$fake = X::fake();
X::like('user-1', 'tweet-1');
X::follow('user-1', 'target-1');
$fake->assertLiked('tweet-1');
$fake->assertFollowed('target-1');
});
it('stubs return values', function () {
$fake = X::fake();
$fake->shouldReturn('searchRecent', new PaginatedResult(
data: [new Post(id: '1', text: 'Stubbed')],
resultCount: 1,
));
$results = X::searchRecent('test');
expect($results->data[0]->text)->toBe('Stubbed');
});Semantic assertions: assertPostCreated, assertPostDeleted, assertLiked, assertRetweeted, assertBookmarked, assertFollowed, assertBlocked, assertMuted, assertRepliedTo, assertSearched, assertNothingPosted, assertNothingCalled, assertForUserCalled.
Plume ships 41 artisan commands for full CLI access to the X API. All commands support --format=json for machine-readable output where applicable.
# Your profile
php artisan plume:me
# Post a tweet
php artisan plume:post --text="Hello from the CLI!"
# Search
php artisan plume:search "laravel" --max-results=20
# Your home timeline
php artisan plume:home --max-results=10 --format=json| Category | Commands |
|---|---|
| Profile | plume:me |
| Posts | plume:post, plume:get-post, plume:delete-post |
| Search | plume:search |
| Timelines | plume:home, plume:timeline, plume:mentions |
| Users | plume:user |
| Likes | plume:like, plume:unlike, plume:likes |
| Retweets | plume:retweet, plume:unretweet |
| Follows | plume:follow, plume:unfollow, plume:followers, plume:following |
| Bookmarks | plume:bookmark, plume:unbookmark, plume:bookmarks |
| Blocks | plume:block, plume:unblock, plume:blocked |
| Mutes | plume:mute, plume:unmute, plume:muted |
| Media | plume:upload |
| Lists | plume:lists, plume:lists:create, plume:lists:get, plume:lists:delete, plume:lists:update, plume:lists:members, plume:lists:add-member, plume:lists:remove-member, plume:lists:tweets, plume:lists:follow, plume:lists:unfollow, plume:lists:pin, plume:lists:unpin |
Commands that modify state (plume:delete-post, plume:unfollow, plume:block, plume:unblock, plume:mute, plume:unmute, plume:lists:delete, plume:lists:remove-member) prompt for confirmation. Pass --force to skip.
Plume ships 15 tools for the Laravel AI SDK (requires PHP 8.4+). Install laravel/ai to use them:
composer require laravel/aiTools are tagged as ai-tools and implement Laravel\Ai\Contracts\Tool:
plume:fetch-tweet, plume:post-tweet, plume:search, plume:home-timeline, plume:my-timeline, plume:mentions, plume:like, plume:retweet, plume:bookmark, plume:bookmarks, plume:follow, plume:followers, plume:following, plume:profile, plume:upload-media
- PHP 8.2+ (AI tools require 8.4+)
- Laravel 11 or 12
See CONTRIBUTING.md. Run composer test, composer phpstan, and composer lint before submitting.
Email joey@jkudish.com to report vulnerabilities. See SECURITY.md.
If you find Plume useful, consider becoming a sponsor.
MIT. See LICENSE.
