{"slug": "how-i-built-an-open-source-social-media-scheduler-with-laravel", "title": "How I Built an Open-Source Social Media Scheduler with Laravel", "summary": "A developer built TryPost, an open-source, self-hostable social media scheduler that supports ten networks including X, LinkedIn, Instagram, and TikTok. The Laravel-based application uses a provider abstraction pattern with platform-specific enums and publishers to handle each network's unique API requirements, while keeping access tokens encrypted in the database. The project is available under the AGPL license and includes an MCP server that enables AI tools like Claude to publish content directly.", "body_md": "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.\n\nSo I built [TryPost](https://github.com/trypost-it/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.\n\nHere'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.\n\nThe 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.\n\nThe 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.\n\nI didn't want that mess leaking into the rest of the app, so platform knowledge lives in one place: an enum.\n\n```\nenum Platform: string\n{\n    case X = 'x';\n    case LinkedIn = 'linkedin';\n    case Instagram = 'instagram';\n    case Threads = 'threads';\n    case Bluesky = 'bluesky';\n    // ...the rest of the ten\n\n    public function maxContentLength(): int\n    {\n        return match ($this) {\n            self::X => 280,\n            self::LinkedIn => 3000,\n            // ...\n        };\n    }\n\n    public function queue(): string\n    {\n        return \"social-{$this->value}\";\n    }\n}\n```\n\nEach network then gets a concrete `Publisher`\n\nclass that knows how to talk to exactly one API, and a tiny service locator picks the right one at publish time:\n\n``` php\nprivate function publisher(): SocialPublisher\n{\n    return match ($this->postPlatform->platform) {\n        Platform::X         => app(XPublisher::class),\n        Platform::LinkedIn  => app(LinkedInPublisher::class),\n        Platform::Instagram => app(InstagramPublisher::class),\n        // ...one line per network\n    };\n}\n```\n\nEvery publisher exposes the same shape: give it a post, get back an id and a URL:\n\n```\nclass XPublisher\n{\n    use HasSocialHttpClient; // shared HTTP client, validation, retries\n\n    public function publish(PostPlatform $postPlatform): array\n    {\n        $this->validateContentLength($postPlatform);\n\n        $response = $this->http($postPlatform->socialAccount)\n            ->post(\"{$this->baseUrl}/tweets\", [\n                'text' => $postPlatform->content,\n            ]);\n\n        return [\n            'id'  => $response->json('data.id'),\n            'url' => \"https://x.com/i/web/status/{$response->json('data.id')}\",\n        ];\n    }\n}\n```\n\nAdding a new network is now a known checklist: add a case to the enum, write one publisher, register it in the `match`\n\n. The calendar, the composer, and the scheduler don't change at all.\n\nConnecting accounts is OAuth, handled with [Laravel Socialite](https://laravel.com/docs/socialite) (plus a couple of custom providers for platforms Socialite doesn't ship, like Instagram's long-lived token exchange).\n\nTokens are the sensitive part, so they never sit in the database as plain text. Laravel's `encrypted`\n\ncast does the work transparently:\n\n``` js\nprotected function casts(): array\n{\n    return [\n        'access_token'     => 'encrypted',\n        'refresh_token'    => 'encrypted',\n        'token_expires_at' => 'datetime',\n        'scopes'           => 'array',\n    ];\n}\n```\n\nThe 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.\n\nTwo rules fixed it. First, try the request before refreshing, and only refresh when you actually get a 401:\n\n```\npublic function publishWithFreshToken(PostPlatform $postPlatform): array\n{\n    try {\n        return $this->publisher()->publish($postPlatform);\n    } catch (TokenExpiredException) {\n        $this->refreshToken($postPlatform->socialAccount);\n\n        return $this->publisher()->publish($postPlatform);\n    }\n}\n```\n\nSecond, when a refresh *is* needed, serialize it with a lock so only one job rotates the token and everyone else reuses the result:\n\n``` php\npublic function refreshToken(SocialAccount $account): void\n{\n    $lock = Cache::lock(\"token-refresh:{$account->id}\", 30);\n\n    if (! $lock->get()) {\n        $account->refresh(); // someone else is rotating it, reuse theirs\n        return;\n    }\n\n    try {\n        match ($account->platform) {\n            Platform::LinkedIn => $this->refreshLinkedIn($account),\n            Platform::X        => $this->refreshX($account),\n            // ...\n        };\n    } finally {\n        $lock->release();\n    }\n}\n```\n\nThe 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.\n\n``` php\n// routes/console.php\nSchedule::command(ProcessScheduledPosts::class)\n    ->everyMinute()\n    ->withoutOverlapping()\n    ->onOneServer();\n```\n\nThe 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`\n\nthat doubles as a lock.\n\n``` php\npublic function handle(): void\n{\n    Post::query()->due()->each(function (Post $post) {\n        $claimed = Post::whereKey($post->id)\n            ->where('status', Status::Scheduled)\n            ->update(['status' => Status::Publishing]);\n\n        if ($claimed === 1) {\n            PublishPost::dispatch($post);\n        }\n    });\n}\n```\n\nOnly the process whose `UPDATE`\n\nactually flips a row from `Scheduled`\n\nto `Publishing`\n\ngets to dispatch. Everyone else sees zero affected rows and moves on. No distributed lock, no extra infrastructure, just one conditional update.\n\nFrom there the work fans out to [Horizon](https://laravel.com/docs/horizon), one queue per platform (`social-x`\n\n, `social-linkedin`\n\n, …). 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:\n\n``` php\npublic function __construct(public PostPlatform $postPlatform)\n{\n    $this->onQueue($postPlatform->platform->queue());\n}\n\npublic function handle(): void\n{\n    if ($this->postPlatform->status === Status::Published) {\n        return; // already done, so a retry is a no-op\n    }\n\n    // refresh-aware publish, then mark published or failed\n}\n```\n\nA 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.\n\nTryPost 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.\n\n```\nclass Workspace extends Model\n{\n    public function account(): BelongsTo\n    {\n        return $this->belongsTo(Account::class);\n    }\n\n    public function socialAccounts(): HasMany\n    {\n        return $this->hasMany(SocialAccount::class);\n    }\n\n    public function posts(): HasMany\n    {\n        return $this->hasMany(Post::class);\n    }\n}\n```\n\nBecause everything carries a `workspace_id`\n\n, 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:\n\n```\npublic function hasActiveSubscription(): bool\n{\n    if (config('trypost.self_hosted')) {\n        return true;\n    }\n\n    return $this->subscribed('default');\n}\n```\n\nOn 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.\n\nLaravel has first-party MCP support now, so the server is just a class listing its tools:\n\n```\n#[Name('TryPost')]\n#[Instructions('Schedule, publish, and pull metrics for social posts.')]\nclass TryPostServer extends Server\n{\n    protected array $tools = [\n        ListPostsTool::class,\n        CreatePostTool::class,\n        PublishPostTool::class,\n        GetPostMetricsTool::class,\n        // ...25+ tools, the same actions you have in the dashboard\n    ];\n}\nphp\n// routes/ai.php\nMcp::web('/mcp/trypost', TryPostServer::class)\n    ->middleware(['auth:api', 'workspace.token']);\n```\n\nEach 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.\n\nThe 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.\n\n```\npublic function schema(JsonSchema $schema): array\n{\n    return [\n        'content'        => $schema->string()->required(),\n        'image_title'    => $schema->string()->required(),\n        'image_keywords' => $schema->array()->items($schema->string())->required(),\n    ];\n}\n```\n\nIt 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.\n\nThat'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.\n\nThe whole thing is open-source and AGPL-licensed. Read every line, fork it, or run it yourself for free:\n\n⭐ Star it on GitHub: [github.com/trypostit/trypost](https://github.com/trypostit/trypost)\n\n🏠 Self-host it (free, forever): [docs.trypost.it](https://docs.trypost.it)\n\nStars are most of how a project like this gets discovered, so if TryPost is useful to you, I'd genuinely appreciate one.\n\nAnd if you'd rather not run a server, there's a hosted version at [trypost.it](https://trypost.it) with the same codebase behind it.\n\nHappy hacking.", "url": "https://wpnews.pro/news/how-i-built-an-open-source-social-media-scheduler-with-laravel", "canonical_source": "https://dev.to/paulocastellano/how-i-built-an-open-source-social-media-scheduler-with-laravel-3g5k", "published_at": "2026-05-27 15:41:22+00:00", "updated_at": "2026-05-27 16:11:55.643261+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "ai-startups"], "entities": ["TryPost", "Buffer", "Hootsuite", "Later", "Laravel", "Vue", "Horizon", "Claude"], "alternates": {"html": "https://wpnews.pro/news/how-i-built-an-open-source-social-media-scheduler-with-laravel", "markdown": "https://wpnews.pro/news/how-i-built-an-open-source-social-media-scheduler-with-laravel.md", "text": "https://wpnews.pro/news/how-i-built-an-open-source-social-media-scheduler-with-laravel.txt", "jsonld": "https://wpnews.pro/news/how-i-built-an-open-source-social-media-scheduler-with-laravel.jsonld"}}