revolution/laravel-notification-mastodon
A Laravel notification channel for sending notifications to Mastodon. Publish status updates directly from your app using Laravel's notification system, with simple configuration and support for the Mastodon API so you can integrate posting into existing workflows.
Install and wire up
composer require revolution/laravel-notification-mastodon
The package is a thin Laravel notification channel — there is nothing to publish in most versions. Configure credentials in config/services.php:
'mastodon' => [
'domain' => env('MASTODON_DOMAIN'), // e.g. https://mastodon.social
'token' => env('MASTODON_TOKEN'),
],
Get a token before writing any code. On your target instance go to Preferences → Development → New application, grant at minimum write:statuses (add write:media if you attach images, read:accounts if you want to resolve handles). Copy the access token into .env. A token without write:statuses will fail with 401/403 at send time — verify this first, it is the single most common reason the first send fails.
First use case — one account, one status
php artisan make:notification PostToMastodon
use Revolution\Notification\Mastodon\MastodonChannel;
use Revolution\Notification\Mastodon\MastodonMessage;
public function via($notifiable): array
{
return [MastodonChannel::class];
}
public function toMastodon($notifiable): MastodonMessage
{
return MastodonMessage::create('Deployed at '.now()->toDateTimeString());
}
Trigger it: Notification::route('mastodon', config('services.mastodon.token'))->notify(new PostToMastodon);
Where to look first
vendor/revolution/laravel-notification-mastodon/src/ — read MastodonChannel.php and MastodonMessage.php before anything else. With ~6 stars and no dependents, the README is not a reliable contract; the source is. Confirmed the autoload namespace there (likely Revolution\Notification\Mastodon) since the fork's namespace may differ from the upstream NotificationChannels\Mastodon examples you'll find on the web.composer.json for the supported Laravel/PHP range before assuming it works with your version — a low-adoption fork may lag behind current Laravel.services.mastodon.domain; confirm whether it appends /api/v1/statuses itself or expects a full base URL.Per-notifiable routing (multi-account)
Rather than a single global account, let the notifiable own its token:
// App\Models\User.php
public function routeNotificationForMastodon(): ?string
{
return $this->mastodon_token; // encrypted cast recommended
}
Keep the global services.mastodon entry as a fallback/default. This is the pattern that scales — a single deployment posting to several accounts, or a SaaS where each tenant has its own instance.
On-demand for arbitrary instances
Notification::route('mastodon', $token)
->notify(new PostToMastodon($status));
If the package reads the domain only from config (not from the route), on-demand against a different instance will not work. Check MastodonChannel::send() — if it doesn't accept a [token, domain] pair, add a small custom channel class of your own that wraps the package's message object and posts to a per-call endpoint. The message class is still reusable.
Visibility and content shaping
return MastodonMessage::create($text)
->unlisted() // unlisted | private | direct
->spoilerText('Spoilers below')
->language('en');
Use direct() for DMs (and remember a direct status with no mentioned accounts is effectively unaddressed — Mastodon requires @handle inside the body for DMs).
Media attachments
return MastodonMessage::create('New chart')
->withMedia([Storage::path('charts/today.png')]);
Mastodon's media upload is two-step (POST /api/v1/media, then attach media_ids to the status). Uploads may return 202 while processing; if the package doesn't poll, image posts will intermittently 422. If you see that, attach only small/ready files or wrap the send in a retry.
Queue the notification, not the channel
class PostToMastodon extends Notification implements ShouldQueue
{
public function via($notifiable): array { return [MastodonChannel::class]; }
}
Mastodon API calls are slow (100–600ms plus media processing) and rate-limited. Never send synchronously in a request cycle. Add ->onQueue('social') and a tries/backoff strategy.
Testing
Notification::fake();
$user->notify(new PostToMastodon);
Notification::assertSentTo($user, PostToMastodon::class);
For the channel itself, Http::fake(['*/api/v1/statuses' => Http::response(['id' => '1'], 200)]) and assert the request body — this is how you catch visibility/spoiler regressions.
Integration tip: pair it with a "social outbox" table (status, target, external id, sent_at) so you get idempotency and an audit trail the package doesn't provide. Store the returned status id/url — users will ask "did it actually post?"
write:statuses is required; media needs write:media. A missing scope surfaces as 401 Unauthorized from the API, not a clear "scope" error. Verify scopes on the instance before debugging code.500; truncate defensively or check /api/v1/instance for max_toot_chars.429 or Retry-After. Rely on queues plus Laravel's backoff(), and consider RateLimited middleware if you fan out to many accounts.mastodon.social vs https://mastodon.social — check whether the channel concatenates /api/... or uses the domain verbatim. A duplicated or missing scheme yields Could not resolve host or a 404 that looks like an auth problem.ShouldBeUnique job.Http::withOptions(['debug' => true]) or use Http::fake() to dump the outgoing payload. Laravel's Http client exceptions omit the response body by default; wrap the send in a try/catch on RequestException and log $e->response->body() — Mastodon returns useful error strings there.composer.json, read the source before upgrading Laravel, and be ready to vendor it. The surface area is small enough that a fork or a ~100-line custom channel is a legitimate exit strategy if the package stalls.MastodonMessage value object is the useful part — even if you replace the channel, keep returning MastodonMessage from toMastodon() so notifications stay portable. If you need idempotency, scheduling, or per-instance routing the package lacks, subclass MastodonChannel and override send() rather than rewriting notifications.How can I help you explore Laravel packages today?