siberfx / social-autopost
Share your Laravel models to LinkedIn, X, Facebook, Instagram and TikTok, each switched on or off, and add more networks later.
Requires
- php: ^8.4
- guzzlehttp/guzzle: ^7.8
- illuminate/auth: ^12.0|^13.0
- illuminate/bus: ^12.0|^13.0
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/encryption: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/log: ^12.0|^13.0
- illuminate/mail: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/session: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- livewire/livewire: ^3.6|^4.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
- livewire/livewire: For the connection cards and share buttons (^3.6|^4.0).
Provides
None
Conflicts
None
Replaces
None
README
Share your Eloquent models to LinkedIn, X, Facebook, Instagram and TikTok, each one switched on or off on its own, and add more networks later with one class and one config entry.
- Each network has two switches: enabled (on or off) and autopost (post automatically when a model goes live).
- A switched-off network is invisible: no UI, no routes (404), no auto-post, no sharing, and its commands say so.
- One connection per network, stored encrypted; one share history for all networks.
- LinkedIn and Facebook list the pages an account can post to; choose one or more and every share goes to each.
- Auto-post runs on a queue, once per model and network, with retries only when a network asks for it.
- Optional Telegram, Slack and email messages, each naming the network.
- Ready-made connection cards and share buttons (Tailwind or Bootstrap 5, Blade or Livewire), or headless.
Requires PHP 8.4+ and Laravel 12 or 13.
Quick start
composer require siberfx/social-autopost php artisan social:install --theme=tailwind php artisan migrate
LINKEDIN_ENABLED=true LINKEDIN_AUTOPOST=true # post automatically when a model goes live X_ENABLED=false FACEBOOK_ENABLED=false INSTAGRAM_ENABLED=false # Instagram and TikTok need an image or video, see below TIKTOK_ENABLED=false
// app/Providers/AppServiceProvider.php → boot() Gate::define('manage-social-autopost', fn (User $user) => $user->is_admin);
use Siberfx\SocialAutopost\Concerns\PostsToSocial; use Siberfx\SocialAutopost\Contracts\ShareableOnSocial; use Siberfx\SocialAutopost\Posts\LinkPost; class Article extends Model implements ShareableOnSocial { use PostsToSocial; public function isLiveForSocial(): bool { return $this->status === 'published'; } public function toSocialPost(): LinkPost { return LinkPost::make(route('blog.show', $this), $this->title) ->description(Str::limit(strip_tags($this->body), 250)) ->commentary("New on the blog: {title}\n\n{url}"); } }
<x-social-autopost::connection network="linkedin" /> <x-social-autopost::connection network="x" /> <x-social-autopost::share-button :model="$article" network="facebook" /> <x-social-autopost::share-button :model="$article" /> {{-- every enabled, connected network --}}
Switching networks on and off
Every network is listed in config/social-autopost.php under networks:
| Switch | Env (LinkedIn example) | Default | Meaning |
|---|---|---|---|
enabled |
LINKEDIN_ENABLED |
false |
Off: hidden everywhere, routes answer 404, sharing and commands refuse it. |
autopost |
LINKEDIN_AUTOPOST |
false |
On: queued automatically when a live model is saved. Manual sharing needs no switch. |
The env names are LINKEDIN_, X_, FACEBOOK_, INSTAGRAM_ and TIKTOK_ followed by ENABLED or AUTOPOST.
A network is only used when it is both switched on and connected.
use Siberfx\SocialAutopost\Facades\Social; Social::enabled(); // collect(['linkedin', 'facebook']) Social::isEnabled('x'); // false Social::isConnected('linkedin'); // true when a connection is stored (no network call) Social::share($article); // every enabled, connected network; one record per network Social::share($article, 'x'); // only X; rethrows if X refuses Social::queue($article, 'facebook'); Social::wasPosted($article, 'linkedin');
share() returns one SharedPost per network it tried, posted or failed. When sharing to all,
one network's failure is recorded and the others still run.
Setting up each network
Each network needs an app in its developer portal. Register the callback URL of each one you
switch on. The default is {your app}/social/{network}/callback; set {NET}_REDIRECT_URI when
your URL differs.
| Network | Developer portal | Register this callback | Credentials | Scopes or permissions |
|---|---|---|---|---|
| developers.linkedin.com → your app | https://example.com/social/linkedin/callback |
LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET |
Products "Share on LinkedIn" and "Sign In with LinkedIn using OpenID Connect" | |
| X | developer.x.com → your app → User authentication settings (OAuth 2.0) | https://example.com/social/x/callback |
X_CLIENT_ID, X_CLIENT_SECRET (optional for a PKCE-only public app) |
tweet.read tweet.write users.read offline.access (set by the package) |
| developers.facebook.com → your app → Facebook Login | https://example.com/social/facebook/callback |
FACEBOOK_APP_ID, FACEBOOK_APP_SECRET |
pages_show_list, pages_manage_posts, pages_read_engagement; the account must manage the Page |
|
| developers.facebook.com → your app → Instagram → API setup with Instagram login → Business login settings | https://example.com/social/instagram/callback |
INSTAGRAM_CLIENT_ID, INSTAGRAM_CLIENT_SECRET (the Instagram app ID and secret, not the Facebook ones) |
instagram_business_basic, instagram_business_content_publish; a Business or Creator account |
|
| TikTok | developers.tiktok.com → your app → Login Kit and Content Posting API (Direct Post on) | https://example.com/social/tiktok/callback |
TIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRET |
user.info.basic, video.publish; verify the domain your media is served from |
Optional settings per network: LINKEDIN_VISIBILITY (PUBLIC or CONNECTIONS),
LINKEDIN_API_VERSION, LINKEDIN_ORGANIZATIONS (also post as organization pages, see
Choosing pages), FACEBOOK_PAGE_ID (the Page selected after connecting; the
first managed Page when empty), FACEBOOK_API_VERSION.
X text is composed to 280 characters, counting every URL as 23. LinkedIn escapes characters it reserves for mentions and hashtags. Facebook sends the link in its own field and takes the preview from the page's Open Graph tags.
Instagram and TikTok: posts need media
Instagram and TikTok post images and videos, not links. Give the post a public URL the network can download; the commentary becomes the caption. LinkedIn, X and Facebook ignore the media.
public function toSocialPost(): LinkPost { return LinkPost::make(route('blog.show', $this), $this->title) ->commentary("{title}\n\nRead more: {url}") ->image($this->cover_url) // Instagram: JPEG. TikTok: a photo post. ->video($this->clip_url); // Instead of the image when set: a Reel / a TikTok video. }
A post without media fails on these two networks with a clear message and is not retried; the others still post. Links in captions are not clickable on either network.
- Instagram posts through a media container. It waits for Instagram to process the media
(
processing_checks×processing_intervalseconds, default 10 × 5); a video that takes longer is retried by the job, and nothing posts twice. The token lasts 60 days and refreshes itself once fewer than 30 remain, whenever the status is read or a post goes out. Instagram has no API to revoke access: disconnecting forgets it here, and the account owner removes the app under Instagram → Settings → Apps and websites.INSTAGRAM_API_VERSIONdefaults tov25.0. - TikTok pulls the media from its URL, so verify that domain or URL prefix in the TikTok
developer portal. The post follows the creator's own settings: comments, duets and stitches stay
off when they turned them off.
TIKTOK_PRIVACY_LEVELisPUBLIC_TO_EVERYONE(default),MUTUAL_FOLLOW_FRIENDS,FOLLOWER_OF_CREATORorSELF_ONLY. Until TikTok audits your app, onlySELF_ONLY(private) posts are allowed; the error says so and names the allowed levels. TikTok returns a publish id, not the video's id, so its notifications carry no link. The access token lasts a day and refreshes itself; the connection lasts a year.
Connecting
Send an admin to route('social-autopost.redirect', 'linkedin'). The callback returns them to
routes.after_connect with ?social=linkedin&state=connected (or cancelled, error,
exchange_failed, invalid_state, missing_code, not_configured).
Social::isConnected('x'); Social::connection('x')->name; // the account name, from the network Social::disconnect('x'); // revokes at the network, then forgets it
Choosing pages
LinkedIn and Facebook can post to more than one place. After connecting, the connection lists every page the account can post to, and people choose one or more of them. Every share then goes to each selected page, with one history row per page.
| Network | Pages |
|---|---|
The member's own profile, plus the organization pages they administer when LINKEDIN_ORGANIZATIONS=true |
|
| Every Page the account manages | |
| X, Instagram, TikTok | One account; pages is empty |
Right after connecting, the selection is what the package posted to before pages existed: the
LinkedIn profile, and on Facebook the FACEBOOK_PAGE_ID Page (or the first managed Page). So
nothing changes until someone picks other pages.
The pages in the response
GET /social/connections, Social::connection('linkedin')->toArray() and the Connected event's
connection all carry the pages. Each page has a selected flag, and selected_pages lists the ids:
{
"network": "linkedin",
"connected": true,
"status": "active",
"name": "Ada Lovelace",
"pages": [
{ "id": "urn:li:person:8kX2…", "type": "member", "name": "Ada Lovelace", "picture": "https://media.licdn.com/…", "selected": true },
{ "id": "urn:li:organization:1042", "type": "organization", "name": "Analytical Engines", "picture": null, "selected": false },
{ "id": "urn:li:organization:2077", "type": "organization", "name": "Difference Ltd", "picture": null, "selected": false }
],
"selected_pages": ["urn:li:person:8kX2…"],
"…": "…"
}
type is member (a LinkedIn profile), organization (a LinkedIn organization page) or page
(a Facebook Page). Page tokens never leave the server.
Choosing them
Over HTTP, behind the same gate as the other routes. Both answer 404 for a network without pages:
| Route | Does |
|---|---|
GET /social/{network}/pages (social-autopost.pages.show) |
{"data": {"pages": [...], "selected_pages": [...]}} |
PUT /social/{network}/pages (social-autopost.pages.update) |
Body {"pages": ["urn:li:organization:1042", "urn:li:person:8kX2…"]}: one or more ids. Answers the connection, or 422 when the list is empty or names an unknown page. A plain HTML form is redirected back with a message. |
curl -X PUT https://example.com/social/linkedin/pages \ -H 'Accept: application/json' -H 'Content-Type: application/json' \ -H "X-XSRF-TOKEN: …" --cookie "…" \ -d '{"pages": ["urn:li:organization:1042", "urn:li:organization:2077"]}'
Or in PHP:
use Siberfx\SocialAutopost\Facades\Social; Social::pages('facebook'); // list<Page>: id, type, name, picture Social::selectedPages('facebook'); // ['1234567'] Social::selectPages('facebook', ['1234567', '7654321']); Social::share($post, 'facebook'); // one SharedPost per selected Page Social::shareToPages($post, 'linkedin', ['urn:li:organization:1042']); // these pages, this once Social::wasPosted($post, 'linkedin', 'urn:li:organization:1042');
The connection cards show a "Post to" list of checkboxes when there is more than one page, and the
Livewire card saves it without a page reload. A signed share request may send pages[] to post to
other pages for that one share (POST /social/{network}/share), and social:share takes --page=
(repeatable). social:status lists each network's pages and marks the selected ones.
When one page fails, the others still post: the failure is recorded for that page, and the share throws only when every page failed. A queued share retries only the pages that have not posted yet.
LinkedIn organization pages
Posting as an organization needs LinkedIn's Community Management API product on your app, which LinkedIn approves on request. Once it is approved, switch it on:
LINKEDIN_ORGANIZATIONS=true
The login then also asks for r_organization_admin and w_organization_social
(networks.linkedin.organization_scopes), and connecting lists the pages where the member is an
administrator or content administrator. Reconnect after switching it on. Leave it off without the
product: LinkedIn refuses a login that asks for scopes the app does not have. If LinkedIn will not
list the organizations, the connection still works with the profile only, and a warning is logged.
Facebook Pages
Every Page the account manages is kept, each with its own Page token, so you can post to several
Pages from one connection. FACEBOOK_PAGE_ID now only sets the Page selected after connecting; it
must still be a Page the account manages.
Upgrading to 1.2
Publish and run the new migration. It adds a target column to social_posts, which records the
page each share went to:
php artisan vendor:publish --tag=social-autopost-migrations php artisan migrate
Existing connections keep working without reconnecting: a stored LinkedIn connection has the
profile as its only page, and a stored Facebook connection has its one Page. Reconnect to list more
pages. Until the migration runs, shares still work, but the page is not recorded and a retried job
cannot tell which pages already posted. A custom network that implements
Contracts\HasPages gets the same page handling as LinkedIn and Facebook.
A ready-made connections page
php artisan social:page # standalone page, configured theme php artisan social:page --layout=layouts.app # inside your layout's @section('content') php artisan social:page --livewire --theme=bootstrap --view=admin.social
The command writes resources/views/social/connections.blade.php. The page has one card for every
enabled network and a "Developer setup" table with each network's callback URL and auto-post
switch. Add a route for it and send people back to it after connecting:
Route::view('/admin/social', 'social.connections') ->middleware(['auth', 'can:manage-social-autopost'])->name('social.connections');
// config/social-autopost.php 'routes' => ['after_connect' => '/admin/social', /* … */],
Without --layout, the page is standalone and loads Tailwind or Bootstrap from a CDN. After that,
the page is your file to change.
Cards show a short note where a network needs one. Instagram and TikTok say a post needs media, and
TikTok mentions private-only posting until it audits your app. Set networks.{key}.hint to
replace a note, or '' to hide it. Instagram cannot be revoked through its API, so its disconnect
question and message tell people where to remove the app. Use networks.{key}.disconnect_note
for your own network.
React and Vue
The cards and share buttons are also available as data, for pages built in React or Vue. Ready-to-copy
components, in TypeScript with Tailwind, are in examples/: SocialConnections
(one card per enabled network) and ShareButton (shares one model now), for both React and Vue.
1. Copy the components into your app:
# React cp -r vendor/siberfx/social-autopost/examples/react resources/js/components/social # Vue cp -r vendor/siberfx/social-autopost/examples/vue resources/js/components/social
2. Build the props on the server with SocialProps. It never includes tokens, and share URLs are
signed for their model:
use Inertia\Inertia; use Siberfx\SocialAutopost\Support\SocialProps; Route::middleware(['auth', 'can:manage-social-autopost'])->group(function () { Route::get('/admin/social', fn () => Inertia::render('SocialConnections', [ 'connections' => SocialProps::connections(), // one card per enabled network 'flash' => SocialProps::flash(), // the message after connecting ])); Route::get('/admin/posts/{post}', fn (Post $post) => Inertia::render('Posts/Show', [ 'post' => $post, 'share' => SocialProps::share($post), // every enabled, connected network 'shareOnTikTok' => SocialProps::share($post, 'tiktok'), ])); });
Set routes.after_connect to '/admin/social', so the network sends people back to the page after
they connect.
3a. Use them in React:
// resources/js/pages/SocialConnections.tsx import SocialConnections from '@/components/social/SocialConnections'; import type { Flash, SocialConnection } from '@/components/social/social'; export default function Page({ connections, flash }: { connections: SocialConnection[]; flash: Flash | null }) { return <SocialConnections connections={connections} flash={flash} />; }
// resources/js/pages/Posts/Show.tsx import ShareButton from '@/components/social/ShareButton'; import type { SocialShare } from '@/components/social/social'; export default function Show({ post, share, shareOnTikTok }: { post: { title: string }; share: SocialShare; shareOnTikTok: SocialShare }) { return ( <article> <h1>{post.title}</h1> <ShareButton share={share} /> <ShareButton share={shareOnTikTok} /> </article> ); }
3b. Use them in Vue:
<!-- resources/js/pages/SocialConnections.vue --> <script setup lang="ts"> import SocialConnections from '@/components/social/SocialConnections.vue'; import type { Flash, SocialConnection } from '@/components/social/social'; defineProps<{ connections: SocialConnection[]; flash: Flash | null }>(); </script> <template> <SocialConnections :connections="connections" :flash="flash" /> </template>
<!-- resources/js/pages/Posts/Show.vue --> <script setup lang="ts"> import ShareButton from '@/components/social/ShareButton.vue'; import type { SocialShare } from '@/components/social/social'; defineProps<{ post: { title: string }; share: SocialShare; shareOnTikTok: SocialShare }>(); </script> <template> <article> <h1>{{ post.title }}</h1> <ShareButton :share="share" /> <ShareButton :share="shareOnTikTok" /> </article> </template>
Without Inertia (a React or Vue app served by the same Laravel app), render the page component
without props: <SocialConnections />. It loads the cards from GET /social/connections itself.
The share button still needs its signed URL from the server, so include SocialProps::share($post)
in the JSON your page already loads for that model. Each card looks like this:
{
"network": "instagram",
"label": "Instagram",
"brand": { "color": "#E4405F", "logo": "<svg …>", "dark": false },
"hint": "Posts need an image or a video. Connect an Instagram Business or Creator account.",
"autopost": true,
"connection": { "connected": true, "status": "active", "name": "siberfx", "expires_at": "2026-12-11T12:00:00+00:00", "days_left": 59, "…": "…" },
"badge": { "label": "Connected", "tone": "success" },
"needs_reconnect": false,
"confirm_disconnect": "Disconnect Instagram? …",
"can_manage": true,
"urls": { "connect": "https://example.com/social/instagram/redirect", "disconnect": "https://example.com/social/instagram/connection" }
}
LinkedIn and Facebook cards also have connection.pages, connection.selected_pages and
urls.pages; selectPages(card, ids) in social.ts saves a choice. See Choosing pages.
Connecting is a full page load (urls.connect is a plain link). Disconnecting and sharing are
JSON requests that send the session cookie and XSRF-TOKEN. See examples/README.md
for the details.
Auto-post
When a model is saved and isLiveForSocial() turns true, the observer queues one job per network
that is enabled, has autopost on, is connected, and has not already posted the model.
Jobs run after the database commit, are unique per model and network while queued, and retry only
on 429, 5xx or no answer. Seeders and Artisan commands do not auto-post unless
autopost.in_console is true.
php artisan queue:work --queue=default
Commands
| Command | Does |
|---|---|
social:install [--theme=] [--headless] |
Publishes config and migrations, sets the UI theme, prints the redirect URI of each enabled network. |
social:page [--theme=] [--layout=] [--section=] [--livewire] [--view=] [--force] |
Writes a connections page with a card for every enabled network. |
social:status |
Every network: enabled, auto-post, connected, status; then each network's pages, the selected ones marked [x]. |
social:disconnect {network} [--force] |
Revokes and forgets one connection. Refuses a switched-off network. |
social:share {network} {model} {id} [--page=*] [--force] |
Shares one model to one network now: to its selected pages, or to the --page ids given. |
social:check-token [--days=] |
Fires TokenExpiringSoon for enabled networks expiring within expiry_warning_days. |
social:test-notification [--channel=] |
Sends a test to the enabled notification channels. |
Notifications
Set SOCIAL_NOTIFY=true and one or more channels:
- Telegram:
SOCIAL_NOTIFY_TELEGRAM,SOCIAL_TELEGRAM_BOT_TOKEN,SOCIAL_TELEGRAM_CHAT_ID - Slack:
SOCIAL_NOTIFY_SLACK,SOCIAL_SLACK_WEBHOOK_URL - Email:
SOCIAL_NOTIFY_MAIL,SOCIAL_NOTIFY_MAIL_TO,SOCIAL_NOTIFY_MAILER
Every message names the network, for example "Shared on X: …" or "The Facebook token expires in 5 days".
Adding a network
- Write a class implementing
Siberfx\SocialAutopost\Contracts\SocialNetwork:key(),label(),isConnected(),connection(),beginConnection(),completeConnection(),disconnect()andpublish(). Keep its tokens in theConnectionStore, and read its settings fromconfig('social-autopost.networks.{key}'). - Add an entry under
networkswithenabled,autopost,classand its own keys. - Optionally add
post_url(with{id}) for links in notifications, andbrandfor its colour and logo. - If the account can post to several places, also implement
Contracts\HasPages:pages(),selectedPages(),selectPages()andpublishToPage(). The page routes, cards, commands and per-page history then work for it too.
'mastodon' => [ 'enabled' => (bool) env('MASTODON_ENABLED', false), 'autopost' => (bool) env('MASTODON_AUTOPOST', false), 'class' => App\Social\MastodonNetwork::class, 'post_url' => 'https://example.social/@me/{id}', 'brand' => ['color' => '#6364FF'], ],
Upgrading from the standalone packages
This package replaces siberfx/linkedin-autopost, siberfx/x-autopost and siberfx/facebook-autopost.
The data and settings differ in these ways:
- Callback URLs change to
/social/{network}/callback. Update the redirect URL in each developer portal, or keep the old URL by setting{NET}_REDIRECT_URI. - Connections are not carried over. Each network stores its own connection in
social_connections, so reconnect each one after upgrading. - Shared history moves to
social_posts. The oldlinkedin_posts,x_postsandfacebook_poststables are not read; keep them until you no longer need the history. - Environment names:
FACEBOOK_AUTO_POSTis nowFACEBOOK_AUTOPOST.LINKEDIN_AUTOPOSTandX_AUTOPOSTare unchanged, and each network now also has{NET}_ENABLED, which defaults to off: set it totruefor every network you use. - Gate: one gate,
manage-social-autopost, replaces the three per-network gates.
Headless
Set SOCIAL_UI=false to ship without views or components. Routes then answer JSON only, and you
use the facade, events and commands yourself.
Events
Each event carries the network key: Connected, Disconnected, Shared, ShareFailed and
TokenExpiringSoon.
Configuration reference
| Key | Purpose |
|---|---|
networks.{key}.enabled, .autopost, .class |
The switches and the driver. |
networks.{key}.* |
The network's own keys (credentials, API version, page id…). |
networks.linkedin.organizations, .organization_scopes |
Also list and post to the member's organization pages (needs the Community Management API). |
networks.{key}.post_url, .brand |
Notification links and the UI colour and logo. |
networks.{key}.hint, .disconnect_note |
The card note, and what disconnecting means when the network cannot revoke ('' hides). |
connection_store |
Where connections are kept (encrypted, DatabaseConnectionStore by default). |
routes |
enabled, prefix (social), name, middleware, after_connect. |
autopost |
in_console, queue_connection, queue. |
ui |
enabled (headless when false), theme. |
notifications |
enabled, events, queued, tries, backoff, channels. |
job |
tries and backoff of the auto-post job. |
expiry_warning_days, status_cache_ttl, http.timeout |
Tuning. |
Tests
composer install vendor/bin/pest vendor/bin/pint --test vendor/bin/phpstan analyse --memory-limit=1G
License
MIT. See LICENSE.