I wanted to get consistent at posting: one calendar across every network, planned a week ahead instead of thrown together the morning of. The problem was that every scheduler I tried wanted my drafts, my analytics, and the access tokens for each connected account to live on its servers instead of mine.
So I built TryPost, an open-source, self-hostable alternative to Buffer, Hootsuite, and Later. It's AGPL, it speaks to ten networks (X, LinkedIn, Facebook, Instagram, TikTok, YouTube, Pinterest, Threads, Bluesky, Mastodon), and you can run the whole thing on your own server for free.
Here's how the core works: the provider abstraction, OAuth and token refresh, the scheduling pipeline, multi-tenancy, and the one piece I hadn't seen in another scheduler, an MCP server that lets Claude or Cursor publish for you.
The stack is Laravel 13, Vue 3 with Inertia, and Horizon, so most of this will look familiar if you've shipped a Laravel app before.
One composer, ten networks
The hardest part of any scheduler isn't the UI. It's that every platform has a different API, different limits, and a different idea of what "a post" even is. X wants 280 characters. LinkedIn wants 3,000. Instagram won't let you post without an image. Threads and Bluesky have their own protocols entirely.
I didn't want that mess leaking into the rest of the app, so platform knowledge lives in one place: an enum.
enum Platform: string
{
case X = 'x';
case LinkedIn = 'linkedin';
case Instagram = 'instagram';
case Threads = 'threads';
case Bluesky = 'bluesky';
// ...the rest of the ten
public function maxContentLength(): int
{
return match ($this) {
self::X => 280,
self::LinkedIn => 3000,
// ...
};
}
public function queue(): string
{
return "social-{$this->value}";
}
}
Each network then gets a concrete Publisher class that knows how to talk to exactly one API, and a tiny service locator picks the right one at publish time:
private function publisher(): SocialPublisher
{
return match ($this->postPlatform->platform) {
Platform::X => app(XPublisher::class),
Platform::LinkedIn => app(LinkedInPublisher::class),
Platform::Instagram => app(InstagramPublisher::class),
// ...one line per network
};
}
Every publisher exposes the same shape: give it a post, get back an id and a URL:
class XPublisher
{
use HasSocialHttpClient; // shared HTTP client, validation, retries
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$response = $this->http($postPlatform->socialAccount)
->post("{$this->baseUrl}/tweets", [
'text' => $postPlatform->content,
]);
return [
'id' => $response->json('data.id'),
'url' => "https://x.com/i/web/status/{$response->json('data.id')}",
];
}
}
Adding a new network is now a known checklist: add a case to the enum, write one publisher, register it in the match. The calendar, the composer, and the scheduler don't change at all.
OAuth, and the token-refresh trap
Connecting accounts is OAuth, handled with Laravel Socialite (plus a couple of custom providers for platforms Socialite doesn't ship, like Instagram's long-lived token exchange).
Tokens are the sensitive part, so they never sit in the database as plain text. Laravel's encrypted cast does the work transparently:
protected function casts(): array
{
return [
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'token_expires_at' => 'datetime',
'scopes' => 'array',
];
}
The interesting bug here isn't expiry. It's refresh races. Several platforms rotate the refresh token every time you use it: refresh once and the old token is dead. Now imagine a workspace with five scheduled posts all firing in the same minute. Five jobs each notice the token is stale, five of them call refresh, and four of them get back "invalid token" because the first one already rotated it. You just disconnected a perfectly healthy account.
Two rules fixed it. First, try the request before refreshing, and only refresh when you actually get a 401:
public function publishWithFreshToken(PostPlatform $postPlatform): array
{
try {
return $this->publisher()->publish($postPlatform);
} catch (TokenExpiredException) {
$this->refreshToken($postPlatform->socialAccount);
return $this->publisher()->publish($postPlatform);
}
}
Second, when a refresh is needed, serialize it with a lock so only one job rotates the token and everyone else reuses the result:
public function refreshToken(SocialAccount $account): void
{
$lock = Cache::lock("token-refresh:{$account->id}", 30);
if (! $lock->get()) {
$account->refresh(); // someone else is rotating it, reuse theirs
return;
}
try {
match ($account->platform) {
Platform::LinkedIn => $this->refreshLinkedIn($account),
Platform::X => $this->refreshX($account),
// ...
};
} finally {
$lock->release();
}
}
Scheduling: cron → queue → publish
The scheduler itself is boring, and that's the point. A command runs every minute, finds posts that are due, and dispatches a job per post.
// routes/console.php
Schedule::command(ProcessScheduledPosts::class)
->everyMinute()
->withoutOverlapping()
->onOneServer();
The detail that matters is making sure a post is published exactly once, even if two workers wake up at the same time or the scheduler overlaps itself. I do that with an atomic status claim: a conditional UPDATE that doubles as a lock.
public function handle(): void
{
Post::query()->due()->each(function (Post $post) {
$claimed = Post::whereKey($post->id)
->where('status', Status::Scheduled)
->update(['status' => Status::Publishing]);
if ($claimed === 1) {
PublishPost::dispatch($post);
}
});
}
Only the process whose UPDATE actually flips a row from Scheduled to Publishing gets to dispatch. Everyone else sees zero affected rows and moves on. No distributed lock, no extra infrastructure, just one conditional update.
From there the work fans out to Horizon, one queue per platform (social-x, social-linkedin, …). That isolation matters: if Instagram's API is having a slow afternoon, those jobs back up on their own queue instead of starving everyone else. The publish job is also idempotent, because a retry must never double-post:
public function __construct(public PostPlatform $postPlatform)
{
$this->onQueue($postPlatform->platform->queue());
}
public function handle(): void
{
if ($this->postPlatform->status === Status::Published) {
return; // already done, so a retry is a no-op
}
// refresh-aware publish, then mark published or failed
}
A single post can target several networks at once, and they can succeed independently. So a post isn't simply "published" or "failed". It can be partially published (X went out, Instagram rejected the image), and the UI tells you which one needs attention instead of pretending the whole thing worked.
Multi-tenancy with workspaces
TryPost is built for agencies and teams, so workspace isolation is enforced at the data layer. There are three layers. The Account is the billing entity, where Cashier lives. A Workspace is a content silo: one brand or one client, with its own connected accounts, posts, brand voice, and members. Everything else (posts, social accounts, labels) hangs off a workspace.
class Workspace extends Model
{
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
Because everything carries a workspace_id, scoping queries to the current workspace keeps one client's data from bleeding into another's. And since the whole thing is also self-hostable, billing is a single switch. Self-hosted installs skip it entirely:
public function hasActiveSubscription(): bool
{
if (config('trypost.self_hosted')) {
return true;
}
return $this->subscribed('default');
}
The part I didn't expect to build: an MCP server
On top of a normal REST API, TryPost ships an MCP server, so an AI assistant like Claude, Cursor, or ChatGPT can drive the whole product in natural language. "Draft a LinkedIn post about our launch and schedule it for Tuesday at 9am" actually works.
Laravel has first-party MCP support now, so the server is just a class listing its tools:
#[Name('TryPost')]
#[Instructions('Schedule, publish, and pull metrics for social posts.')]
class TryPostServer extends Server
{
protected array $tools = [
ListPostsTool::class,
CreatePostTool::class,
PublishPostTool::class,
GetPostMetricsTool::class,
// ...25+ tools, the same actions you have in the dashboard
];
}
// routes/ai.php
Mcp::web('/mcp/trypost', TryPostServer::class)
->middleware(['auth:api', 'workspace.token']);
Each tool is a small class with a typed schema, validation, and a handler that calls the same Action the web UI calls, so there's no second implementation to keep in sync. The auth middleware ties the request to the right workspace, so an assistant can only touch the data its token is scoped to.
The content generation behind "draft a post" is a set of AI agent classes using structured output, so the model can't hand back free-form text I'd have to parse. It returns a caption, an image hook, and image keywords in a fixed shape. The brand voice (tone, language, examples) comes from the workspace, and the prompts themselves live in Blade templates rather than buried in PHP strings, which keeps them editable without touching code.
public function schema(JsonSchema $schema): array
{
return [
'content' => $schema->string()->required(),
'image_title' => $schema->string()->required(),
'image_keywords' => $schema->array()->items($schema->string())->required(),
];
}
It turns out a scheduler with a clean API surface is most of the way to being something an AI agent can operate. The MCP layer was a few hundred lines on top of plumbing that already existed.
Try it
That's the core of it: enum-driven platforms, encrypted tokens with race-safe refresh, an atomic-claim scheduler on Horizon, workspace isolation, and an MCP server stitched onto the same actions the UI uses.
The whole thing is open-source and AGPL-licensed. Read every line, fork it, or run it yourself for free:
⭐ Star it on GitHub: github.com/trypostit/trypost
🏠 Self-host it (free, forever): docs.trypost.it
Stars are most of how a project like this gets discovered, so if TryPost is useful to you, I'd genuinely appreciate one.
And if you'd rather not run a server, there's a hosted version at trypost.it with the same codebase behind it.
Happy hacking.
Top comments (0)