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

v1.2.0 2026-02-22 18:50 UTC

README

X (Twitter) API v2 client for Laravel.

Plume — X API v2 client for Laravel

Tests Packagist Version Packagist Downloads PHP Version License Sponsor

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.