laravel-notification-channels/facebook-poster
Send Laravel notifications to Facebook Pages with this dedicated notification channel. Quickly publish posts, links, and media to a page via the Facebook Graph API, integrating Facebook publishing directly into your app's existing notification workflow.
Confirm the floor before you type anything. The 6.2.0 release drops Laravel 10 and PHP 8.1, so you need Laravel 11+ / PHP 8.2+. Verify against composer.json in the repo rather than the release note, then pin explicitly:
composer require laravel-notification-channels/facebook-poster:^6.2
php -r "echo PHP_VERSION;"
If composer require resolves to an older major, that's your signal the app is below the floor — stop and fix the platform first. composer why-not laravel-notification-channels/facebook-poster ^6.2 names the blocker in one line.
Configuration lives in config/services.php, not a published package config. The channel reads a single Page ID and access token:
// config/services.php
'facebook_poster' => [
'page_id' => env('FACEBOOK_PAGE_ID'),
'access_token' => env('FACEBOOK_PAGE_ACCESS_TOKEN'),
],
Both values come from Meta's side, not from code: a Facebook App, a Page the app can post to, and a Page access token with pages_manage_posts (and pages_read_engagement if you want to read back what you posted). Exchange the short-lived user token for a long-lived one, then use /me/accounts to get the Page token. Do this before writing the notification — it's the step that eats the day, and it usually belongs to whoever owns the Page, not to you.
First use case: a release/changelog announcement. It's the lowest-stakes, highest-signal thing to wire up and it exercises the whole path.
// app/Notifications/ReleasePublished.php
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use NotificationChannels\FacebookPoster\FacebookPosterChannel;
use NotificationChannels\FacebookPoster\FacebookPosterMessage;
class ReleasePublished extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Release $release) {}
public function via(object $notifiable): array
{
return config('services.facebook_poster.enabled')
? [FacebookPosterChannel::class]
: [];
}
public function toFacebookPoster(object $notifiable): FacebookPosterMessage
{
return FacebookPosterMessage::create()
->body("v{$this->release->version} is live: {$this->release->summary}")
->url($this->release->changelog_url);
}
}
Smoke-test it out of band first. Before touching via() on a real domain notification, prove the credential works with a one-off:
// tinker
Notification::route(FacebookPosterChannel::class, config('services.facebook_poster.page_id'))
->notify(new \App\Notifications\TestFacebookPost);
If that posts, everything after is Laravel plumbing. If it 400s, it's a token/permission/app-review problem and no amount of code will help — check the token in Meta's Access Token Debugger and confirm the app isn't still in Development Mode (where only admins/testers can post).
Where to look first in the source: the package is genuinely small — the channel class and FacebookPosterMessage. Read both. You'll learn (a) the exact Graph API version it pins, (b) whether the Page ID comes from config or from a routeNotificationFor... method on the notifiable, and (c) what send() returns. Those three facts drive every decision below.
Keep the package behind one class. The channel is thin enough that if it goes quiet you can vendor it in an afternoon — but only if exactly one file in your codebase imports its namespace. Wrap message construction:
// app/Services/FacebookPage.php
namespace App\Services;
use NotificationChannels\FacebookPoster\FacebookPosterMessage;
class FacebookPage
{
public function message(string $body, ?string $url = null, ?string $image = null): FacebookPosterMessage
{
$message = FacebookPosterMessage::create()->body($body);
if ($url) $message->url($url);
if ($image) $message->image($image);
return $message;
}
}
Now the package namespace appears in one notification and one service. Swapping to a direct Graph call later is a contained diff, not a refactor.
Persist the payload, not the message object. Dispatch the notification with an ID or a model; build the FacebookPosterMessage inside toFacebookPoster(). Message objects built at dispatch time and sent via a delayed/queued job will serialize fine but read as stale — you want the body derived from the record at send time so a delayed post reflects the final copy.
// good
public function __construct(public int $socialPostId) {}
public function toFacebookPoster($notifiable)
{
$post = SocialPost::findOrFail($this->socialPostId);
return $this->facebook->message($post->body, $post->link, $post->image_url);
}
Separate "compose" from "publish". For anything user-facing, store a social_posts row (pending → queued → published / failed, plus external_id). This gives you a moderation gate, an audit trail, and — critically — a place to record the Facebook post ID so you can detect and clean up duplicates after a retry. The package posts; it does not track state for you.
Capture the response in a listener. Laravel fires NotificationSent with the channel's return value. If the channel returns the Graph response (check the source), this is how you get the post ID without forking:
Event::listen(NotificationSent::class, function (NotificationSent $event) {
if ($event->channel === FacebookPosterChannel::class && $event->response) {
SocialPost::where(...)->update([
'external_id' => $event->response->getDecodedBody()['id'] ?? null,
'status' => 'published',
]);
}
});
Gate by environment and feature flag inside via(). This is the single highest-value pattern in the list. A staging deploy that posts to the live Page is a real incident:
public function via($notifiable): array
{
return config('services.facebook_poster.enabled') ? [FacebookPosterChannel::class] : [];
}
Set FACEBOOK_POSTER_ENABLED=false in .env.example and in CI. Returning an empty array from via() is a clean, supported no-op — no exception, no retry storm.
Queue it, but bound the retries. implements ShouldQueue inherits your queue's retry policy; that's the main reason to use this package at all. Add public $tries = 3; and a backoff() that matches Graph's rate-limit behaviour. Do not let a 24-hour retry window apply to a channel that has real side effects — see the duplicate-post gotcha below.
Test with a fake, not with a live Page. Two layers:
// unit: the notification built the message we expected
Notification::fake();
$user->notify(new ReleasePublished($release));
Notification::assertSentTo($user, ReleasePublished::class, function ($n) use ($release) {
return $n->toFacebookPoster($user)->body === "v{$release->version} is live: {$release->summary}";
});
For the channel's HTTP behaviour, check whether the package uses Laravel's HTTP client (likely in 6.x) — if so, Http::fake(['graph.facebook.com/*' => Http::response(['id' => '123_456'])]) gives you a real end-to-end test including failure paths. If it uses the older facebook/graph-sdk, you'll need a Guzzle mock handler instead. Confirm which by reading composer.json.
Route by domain event, not by scattered calls. $post->publish() → fires PostPublished → listener queues the notification. Keeps the social concern out of your controllers and gives you exactly one call site to disable when the Page is compromised, the token is dead, or marketing asks you to stop posting for a week.
A NotificationFailed isn't always a failed post. If Graph returns a 500 or your queue worker times out mid-request, the post may have succeeded anyway. On retry you post twice. Mitigate by checking the Page feed (GET /{page-id}/posts) in your retry handler before re-sending, or by accepting the occasional duplicate and documenting it. Never configure unbounded $tries on this channel.
Token expiry is the number one silent failure. Long-lived Page tokens obtained from a long-lived user token expire in ~60 days unless you use a System User token. When it expires you get OAuth error code 190 — and if the channel doesn't throw cleanly, posts vanish with no trace. Add a scheduled check hitting /debug_token (or a canary post to a private test Page) and alert when expiry is under 14 days. Make the token rotatable without a deploy: read it from your secret manager, not a baked env var, or at minimum know that php artisan config:cache freezes the value until you re-cache.
env() returns null after config:cache. You're calling it inside config/services.php, which is correct — but if you're tempted to shortcut and call env('FACEBOOK_PAGE_ID') directly in a notification, it will work locally and silently return null in production. Always go through config().
One Page per install is baked in. Page ID comes from a single config entry. Multi-Page, multi-tenant, or "post to whichever Page this content belongs to" requires either a custom channel that overrides the Page ID resolution or one config per tenant with a runtime config swap — the latter is fragile under config:cache and Octane. If multi-Page is on the roadmap, build your adapter to accept a Page parameter now, even if it ignores it.
Images need a publicly reachable URL. Facebook fetches the image server-side. http://localhost, signed URLs that expire in 60 seconds, or anything behind auth will fail — often with a vague error. Use a stable, public CDN URL, and verify the aspect ratio and dimensions against Facebook's link-preview rules before blaming the package.
Graph API version drift is the real long-term risk. The package pins a version in its source. Meta deprecates versions on roughly a two-year cadence, and when the pin goes stale, posts break with a change on nobody's side of the wire. Check the pinned version on adoption, put a calendar reminder near its deprecation date, and watch the repo for version bumps. With ~141 stars and no listed dependents, you are one of very few people who will notice first — plan on filing the issue yourself.
App Review gates production. While the Meta app is in Development Mode, only admins/testers can post, and only to Pages they manage. pages_manage_posts needs App Review with a screencast of the actual integration. Budget for this in scheduling; it is not an engineering task and it is not instant.
Local development will post to production unless you stop it. There's no sandbox environment on Facebook's side. Your only defences are the enabled flag in via() and, ideally, a separate "dev" Page (FACEBOOK_PAGE_ID differing by environment in your secret manager). Do both.
Don't fight the builder; fork it. Need scheduled_publish_time, published: false, or a link-share attachment with a custom title? The message class and channel together are a few hundred lines. Copying them into app/Notifications/Channels/ and adding fields is faster and safer than monkey-patching or submitting a PR and waiting. Keep the namespace out of your domain code and this stays a 30-minute decision.
Version-pin and lockfile-rollback. ^6.2 plus a committed composer.lock. Because this dependency has a history of dropping framework versions in minor releases (6.2.0 is a breaking platform change by any reasonable reading), treat every composer update that touches it as a reviewed change with a known rollback — git checkout composer.lock && composer install.
Log the Graph error body. Facebook's responses include error.message, error.code, and error.error_subcode, which distinguish "token expired" (190/463) from "duplicate content" (506) from "rate limited" (4/32). If the channel doesn't surface these, wrap the send in a try/catch and Log::warning() the decoded body — otherwise you'll be debugging "nothing was posted" with zero information.
How can I help you explore Laravel packages today?