mltstephane/laravel-buffer

A Laravel package to interact with the Buffer (buffer.com) GraphQL API v2: accounts, channels, posts and local post sync.

Maintainers

Package info

github.com/MltStephane/laravel-buffer

pkg:composer/mltstephane/laravel-buffer

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.0.1 2026-08-05 20:30 UTC

This package is auto-updated.

Last update: 2026-08-10 16:01:10 UTC


README

Latest Version on Packagist Total Downloads run-tests

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_mode and scheduling_type propagate 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 the response payload are stored;
  • on failure: the row is marked failed with the error payload in error, 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.