jkudish / plume
X API v2 client for Laravel — facades, typed DTOs, test fakes, and user-scoped operations.
Fund package maintenance!
jkudish
Installs: 95
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/jkudish/plume
Requires
- php: ^8.2
- illuminate/http: ^11.0 || ^12.0
- illuminate/support: ^11.0 || ^12.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/ai: ^0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0 || ^10.0
- pestphp/pest: ^4.0
Suggests
- laravel/ai: Required for using the 15 built-in AI tools (requires PHP 8.4+)
- dev-main
- v1.2.0
- v1.1.0
- v1.0.0
- dev-docs/readme-improvements
- dev-feat/bookmarks-media-fields
- dev-docs/v1.1.0-polish
- dev-feat/artisan-commands
- dev-fix/readme-corrections
- dev-docs/sponsor-badge
- dev-docs/sponsor-readme
- dev-dependabot/github_actions/actions/cache-5
- dev-dependabot/github_actions/actions/checkout-6
This package is auto-updated.
Last update: 2026-02-23 12:51:33 UTC
README
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.
Install
composer require jkudish/plume php artisan vendor:publish --tag=x-config
Add to .env:
X_BEARER_TOKEN=your-bearer-token X_CLIENT_ID=your-client-id X_CLIENT_SECRET=your-client-secret
Quick Start
use 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;
Why Plume?
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.
What's Covered
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).
Key Features
Typed DTOs with Active Record Methods
$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();
Automatic Pagination
$page = X::userTimeline('user-id', maxResults: 100); while ($page !== null) { foreach ($page->data as $post) { process($post); } $page = $page->nextPage(); }
User-Scoped Client
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, ]; } }
OAuth 2.0 with Auto-Refresh
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'], ]); });
Rate-Limit Awareness
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.
Media Upload
// 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']);
Test Fakes
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.
Artisan Commands
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.
AI Tools
Plume ships 15 tools for the Laravel AI SDK (requires PHP 8.4+). Install laravel/ai to use them:
composer require laravel/ai
Tools 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
Requirements
- PHP 8.2+ (AI tools require 8.4+)
- Laravel 11 or 12
Contributing
See CONTRIBUTING.md. Run composer test, composer phpstan, and composer lint before submitting.
Security
Email joey@jkudish.com to report vulnerabilities. See SECURITY.md.
Sponsoring
If you find Plume useful, consider becoming a sponsor.
License
MIT. See LICENSE.
