mltstephane / laravel-buffer
A Laravel package to interact with the Buffer (buffer.com) GraphQL API v2: accounts, channels, posts and local post sync.
Requires
- php: ^8.2
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.13
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^2.34|^3.0
- pestphp/pest-plugin-laravel: ^2.2|^3.0
- phpstan/phpstan: ^1.11|^2.0
README
A Laravel package to interact with the Buffer API v2 (GraphQL). Manage your account, channels and posts — and keep a local mirror of your posts when you need one.
use Mltstephane\LaravelBuffer\Facades\Buffer; Buffer::posts()->queue('Hello world!', $channelId); Buffer::posts()->schedule('Launch day!', $channelId, now()->addDays(2));
Requirements
- PHP 8.2+
- Laravel 10, 11, 12 or 13
Installation
composer require mltstephane/laravel-buffer
The package auto-registers its service provider (BufferServiceProvider). Then publish the configuration and the optional migration:
php artisan buffer:install
The installer publishes config/buffer.php, publishes the buffer_posts migration and asks whether you want to run it. You can publish only the config if you do not need local persistence:
php artisan vendor:publish --tag="laravel-buffer-config"
Configuration
Add your Buffer API key to your .env file:
BUFFER_API_KEY=your-buffer-api-key BUFFER_ORGANIZATION_ID=your-default-organization-id
| Key | Env | Default | Description |
|---|---|---|---|
api_key |
BUFFER_API_KEY |
null |
Buffer API v2 key used for authentication. |
base_url |
BUFFER_API_URL |
https://api.buffer.com |
GraphQL endpoint. Override for proxies/staging. |
organization_id |
BUFFER_ORGANIZATION_ID |
null |
Default organization used by resources that require one. |
timeout |
BUFFER_TIMEOUT |
30 |
Request timeout in seconds. |
connect_timeout |
BUFFER_CONNECT_TIMEOUT |
5 |
Connection timeout in seconds. |
retry.times |
BUFFER_RETRY_TIMES |
2 |
Total number of attempts (1 initial + 1 retry). Set to 0 to disable. |
retry.sleep |
BUFFER_RETRY_SLEEP |
100 |
Milliseconds between attempts (overridden by Retry-After on 429). |
throw_on_http_error |
BUFFER_THROW_ON_HTTP_ERROR |
true |
Whether non-200 HTTP statuses throw typed exceptions. |
throw_on_graphql_error |
BUFFER_THROW_ON_GRAPHQL_ERROR |
true |
Whether a GraphQL errors[] block throws. |
headers |
— | User-Agent: mltstephane/laravel-buffer/1.0 |
Extra headers merged into every request. |
Security note: keep your API key in the environment, never commit it, and use a key with the least privileges needed for your use case.
Quick start
use Mltstephane\LaravelBuffer\Facades\Buffer; // Account $account = Buffer::account()->get(); $organizations = Buffer::account()->organizations(); // Channels $channels = Buffer::channels()->all(); // uses config('buffer.organization_id') $channels = Buffer::channels()->all('org_123'); // explicit organization $channel = Buffer::channels()->find('ch_123'); // Posts $post = Buffer::posts()->queue('Hello world!', 'ch_123'); $post = Buffer::posts()->schedule('Launch day!', 'ch_123', now()->addDays(2)); $post = Buffer::posts()->create([ 'text' => 'Share now', 'channelId' => 'ch_123', 'mode' => 'shareNow', 'needsApproval' => false, ]);
Usage
The facade and the service
Buffer is a facade backed by the Mltstephane\LaravelBuffer\Buffer service, bound in the container. You can also resolve it directly:
use Mltstephane\LaravelBuffer\Buffer as BufferService; $buffer = app(BufferService::class);
Account
use Mltstephane\LaravelBuffer\Facades\Buffer; $account = Buffer::account()->get(); $account->id; // string $account->email; // string $account->name; // string $account->timezone; // ?string $account->organizations; // array<int, Organization> — each with ->id and ->name
Channels
use Mltstephane\LaravelBuffer\Facades\Buffer; // All channels of an organization (defaults to config('buffer.organization_id')) $channels = Buffer::channels()->all('org_123'); foreach ($channels as $channel) { $channel->id; // string $channel->name; // string $channel->service; // string, e.g. "twitter" $channel->descriptor; // string, e.g. "Twitter Profile" $channel->avatar; // ?string $channel->externalLink; // ?string $channel->isDisconnected; // bool $channel->isLocked; // bool $channel->isQueuePaused; // bool $channel->timezone; // ?string } // One channel $channel = Buffer::channels()->find('ch_123');
If neither an explicit organization id nor config('buffer.organization_id') is available, a GraphQLException with a clear message is thrown.
Posts
Create
use Mltstephane\LaravelBuffer\Facades\Buffer; // Add to the channel queue Buffer::posts()->queue('Hello queue!', 'ch_123'); // Schedule at a custom date (ISO 8601, UTC) Buffer::posts()->schedule('Launch day!', 'ch_123', now()->addDays(2)); Buffer::posts()->schedule('With notification', 'ch_123', now()->addDay(), \Mltstephane\LaravelBuffer\Enums\SchedulingType::Notification); // Full control — the input array is passed through to CreatePostInput Buffer::posts()->create([ 'text' => 'Hello', 'channelId' => 'ch_123', 'mode' => 'customScheduled', 'schedulingType' => 'automatic', 'dueAt' => now()->addHours(3), 'saveToDraft' => true, // draft instead of scheduling 'needsApproval' => false, 'tagIds' => ['tag_1'], 'assets' => [ ['link' => ['url' => 'https://example.com/article']], ], ]);
DateTimeInterface values anywhere in the input are converted to ISO-8601 UTC strings automatically (the same instant, normalized to UTC). Pass pre-formatted strings if you prefer — use UTC offsets (Z or +00:00) to avoid surprises.
When create() does not receive schedulingType and needsApproval, they default to automatic and false (both are required by CreatePostInput).
Share modes (ShareMode enum): addToQueue, customScheduled, shareNow, shareNext.
Read and paginate
use Mltstephane\LaravelBuffer\Facades\Buffer; $page = Buffer::posts()->list('org_123'); $page->posts; // array<int, Post> $page->pageInfo->endCursor; // ?string — pass to $after for the next page $page->pageInfo->hasNextPage; // bool // Next page $next = Buffer::posts()->list('org_123', [], 50, $page->pageInfo->endCursor); // Filters (PostsFiltersInput) $page = Buffer::posts()->list('org_123', [ 'channelIds' => ['ch_123'], 'status' => ['scheduled', 'sent'], 'startDate' => now()->subWeek(), ]); // Sort (PostSortInput entries) $page = Buffer::posts()->list('org_123', [], 50, null, [ ['field' => 'dueAt', 'direction' => 'DESC'], ]); // A single post $post = Buffer::posts()->find('post_123');
Edit, delete, queue management
use Mltstephane\LaravelBuffer\Facades\Buffer; // Edit — the input is merged over the post id (EditPostInput) Buffer::posts()->edit('post_123', ['text' => 'Updated text']); // Delete Buffer::posts()->delete('post_123'); // Move within the channel queue (top or bottom) — experimental API use Mltstephane\LaravelBuffer\Enums\QueuePosition; Buffer::posts()->moveInQueue('post_123', QueuePosition::Top);
The Post DTO
$post->id; // string $post->status; // string — Buffer API status (draft|scheduled|sending|sent|error|needs_approval) $post->text; // string $post->channelId; // string $post->channelService; // string $post->dueAt; // ?Carbon $post->sentAt; // ?Carbon $post->sharedNow; // bool $post->shareMode; // ?string $post->schedulingType; // ?string $post->isCustomScheduled;// bool $post->externalLink; // ?string $post->errorMessage; // ?string — extracted from error.message when present $post->createdAt; // Carbon $post->updatedAt; // Carbon
Publications (multi-channel)
A publication fans a shared post out to a selected set of channels. It follows a two-phase lifecycle: create() stores a local draft (zero API calls), publish() pushes one post per channel, edit() refreshes a draft and cancel() aborts a publication.
use Mltstephane\LaravelBuffer\Facades\Buffer; use Mltstephane\LaravelBuffer\Models\BufferPublication; // 1. Draft locally — no API call. One BufferPost child is created per channel. $publication = Buffer::publications()->create([ 'text' => 'Launching today!', 'channelIds' => ['ch_1', 'ch_2', 'ch_3'], // Optional per-channel text override (fixed at creation). 'textOverrides' => ['ch_2' => 'Launching today! — on our other page too'], 'share_mode' => \Mltstephane\LaravelBuffer\Enums\ShareMode::CustomScheduled, 'due_at' => now()->addDay(), 'scheduling_type' => \Mltstephane\LaravelBuffer\Enums\SchedulingType::Automatic, ]); // 2. Publish: one createPost per channel, with per-channel failure isolation. $result = Buffer::publications()->publish($publication); $result->successful(); // list of ChannelPublishResult (no error) $result->failed(); // list of ChannelPublishResult (with error) $result->allSucceeded(); // bool // 3. Edit a draft before it is pushed (throws if already pushed): Buffer::publications()->edit($publication, ['text' => 'Updated text']); // 4. Cancel: pushed children are deleted remotely, the rest marked cancelled. Buffer::publications()->cancel($publication);
The aggregate BufferPublication stores a PublicationStatus (draft, scheduled, sent, failed, cancelled, partial) recomputed from its children after each publish/cancel. Publishing twice or editing after a push throws InvalidArgumentException. Local persistence is required: both buffer_publications and the updated buffer_posts migrations are published by buffer:install.
Note:
edit()propagates the shared text only to children without a per-channel override;due_at,share_modeandscheduling_typepropagate to all children. Channel targets are fixed at creation.
Raw GraphQL (escape hatch)
Every resource is built on a low-level client. When you need something the resources do not cover, drop down to it:
use Mltstephane\LaravelBuffer\Facades\Buffer; $data = Buffer::graphql()->query('query { account { id } }'); $data = Buffer::graphql()->mutate('mutation { ... }', ['input' => [...]]);
Or resolve Mltstephane\LaravelBuffer\BufferClient directly. The constructor accepts an optional API key override, useful for multi-tenant setups:
$client = new \Mltstephane\LaravelBuffer\BufferClient('tenant-specific-key');
Local persistence & one-way sync
The package ships an optional buffer_posts table to mirror your Buffer posts locally. The BufferPost model uses ULID primary keys and casts status, share_mode and scheduling_type to their enums.
use Mltstephane\LaravelBuffer\Models\BufferPost; // Create a local draft and push it to Buffer: $post = BufferPost::factory()->create([ 'text' => 'Local draft', 'channel_id' => 'ch_123', 'share_mode' => \Mltstephane\LaravelBuffer\Enums\ShareMode::AddToQueue, 'status' => \Mltstephane\LaravelBuffer\Enums\PostStatus::Draft, ]); Buffer::posts()->createFrom($post); // pushes + sets buffer_post_id, status, response // Later, edit the local row and push the change: $post->update(['text' => 'Edited locally']); Buffer::posts()->updateFrom($post); // Delete remotely and mark the local row as cancelled: Buffer::posts()->deleteFrom($post);
The sync helpers (createFrom, updateFrom, deleteFrom) call the API and update the local row:
- on success:
buffer_post_id,status(mapped from the API status) and theresponsepayload are stored; - on failure: the row is marked
failedwith the error payload inerror, and the exception is re-thrown.
Model statuses (PostStatus enum): draft, scheduled, sent, failed, cancelled. Scopes are available: BufferPost::draft(), ::scheduled(), ::sent(), ::failed().
Note: local persistence is optional. If you only call the API, the table is not needed. The sync is one-way (local → Buffer). To import remote posts into your database, list them with
posts()->list()and store the DTOs yourself.
Errors & retries
Every failure is a typed exception extending Mltstephane\LaravelBuffer\Exceptions\BufferException:
| Exception | When | Extra |
|---|---|---|
AuthenticationException |
HTTP 401 | Invalid/unauthorized API key. |
RateLimitExceededException |
HTTP 429 after all retries | retryAfter (?int) — seconds from the Retry-After header. |
ApiException |
Any other HTTP error | status (int), payload (decoded body). |
GraphQLException |
GraphQL errors[] in the body (including MutationError branches) |
messages(): array, errors (raw block). |
BufferNetworkException |
The API could not be reached (timeout, DNS, refused connection) | The original message is preserved. |
use Mltstephane\LaravelBuffer\Exceptions\RateLimitExceededException; try { Buffer::posts()->queue('Hello!', $channelId); } catch (RateLimitExceededException $e) { // wait $e->retryAfter seconds before retrying } catch (\Mltstephane\LaravelBuffer\Exceptions\BufferException $e) { // any other Buffer API failure }
Retry policy: only HTTP 429 responses are retried (retry.times attempts total). Between attempts the package waits retry.sleep milliseconds, or the Retry-After value when the header is present. Other 4xx errors are never retried. Set retry.times to 0 to disable retrying, and set throw_on_http_error / throw_on_graphql_error to false to make the client return the decoded body instead of throwing.
Testing
composer test
The test suite uses Http::fake() — no real requests are ever made. Fixtures live in tests/Fixtures.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details (coming soon).
Security
If you discover a security vulnerability within this package, please open an issue or contact the maintainer directly. All security vulnerabilities will be promptly addressed.
Credits
License
The MIT License (MIT). Please see License File for more information.