revolution/laravel-mastodon-api
Laravel Mastodon API is a package for integrating Mastodon into Laravel applications. It provides a convenient client for OAuth authentication and API requests, letting you post statuses, manage accounts, and build federated social features with familiar Laravel syntax.
Install and configure
composer require revolution/laravel-mastodon-api
php artisan vendor:publish --tag="mastodon-api-config"
Set the minimum env values in .env:
MASTODON_DOMAIN=mastodon.social
MASTODON_TOKEN=your_user_access_token
The config file (config/mastodon-api.php) also carries client_id, client_secret, redirect_uri, and scopes — you only need those for the OAuth flow, not for token-based API calls.
Where to look first
config/mastodon-api.php — the only config surface; confirms the domain/token keys and scope defaults.src/MastodonApi.php (or the facade's underlying class) — the package is thin, so this file is the API reference. Read it before the README, because it tells you which HTTP verbs and builder methods actually exist in your pinned version.First use case — verify the credentials, then post
use Revolution\MastodonApi\Facades\MastodonApi;
// 1. Smoke test: does the token/domain pair work?
$me = MastodonApi::get('api/v1/accounts/verify_credentials');
$me['username']; // => "your_handle"
// 2. First real action
MastodonApi::post('api/v1/statuses', [
'status' => 'Hello from Laravel',
'visibility' => 'unlisted',
]);
If verify_credentials returns 401, the problem is the token or the domain — not your code. Fix that before anything else.
Treat it as a typed HTTP client, not an SDK. The package gives you an authenticated, domain-aware get/post/delete (and usually put/patch) against a Mastodon instance. Everything else — DTOs, resources, retries — is yours to add. The most common pattern is a thin service wrapper:
final class MastodonClient
{
public function postStatus(string $status, array $options = []): array
{
return MastodonApi::post('api/v1/statuses', array_merge(
['status' => $status, 'visibility' => 'public'],
$options,
['idempotency_key' => ...] // see Gotchas
));
}
}
Wrapping it means a future replacement (or a switch to danielmatsuda/mastodon-api or raw Http:: calls) is a one-file change.
Multi-account / multi-instance. The config-driven singleton is fine for a single bot account, but for anything user-facing you need per-account clients. Typical shape: a mastodon_accounts table with domain, access_token, scopes, then build the client at runtime using the package's domain/token setters on the facade (MastodonApi::domain($domain)->token($token)->...) — check the exact builder method names in src/ for your version, as this is the most likely place for a breaking change between releases.
OAuth onboarding. For "log in with Mastodon": register the app once per instance (client_id/client_secret differ per Mastodon server — you cannot ship one global pair), redirect the user to the instance's /oauth/authorize with the redirect_uri from config, then exchange the returned code for a token. Persist the domain alongside the token — a token is meaningless without it, and this is the #1 data-modelling mistake people make.
Queue the writes. Mastodon posts are slow-ish and rate-limited. Dispatch a job per status rather than calling inside a request:
PostToMastodon::dispatch($status)->onQueue('social');
Retry with backoff, but see the idempotency note below.
Test with Http::fake(). Because the package rides on Laravel's HTTP client, you get fakes for free:
Http::fake([
'mastodon.social/api/v1/statuses' => Http::response(['id' => '123'], 200),
]);
Fake the full URL including the domain — the package builds absolute URLs, so '*/api/v1/statuses' patterns are safer across configs.
Domain is a host, not a URL. Put mastodon.social in MASTODON_DOMAIN, not https://mastodon.social. A trailing slash or scheme produces doubled slashes or malformed URLs that fail in confusing ways (often as a 404 rather than a clear error).
Config caching bites immediately. After changing .env, run php artisan config:clear. In production with config:cache, an edited .env is invisible until you re-cache.
No built-in rate-limit handling. Mastodon returns X-RateLimit-Remaining and X-RateLimit-Reset; the package won't retry for you. Inspect the response headers and back off yourself, or you'll get 429s during any bulk operation. Budget roughly 300 requests per 5 minutes per account (instance-dependent).
Use idempotency keys for status creation. Send an Idempotency-Key header on POST /api/v1/statuses so retries after a timeout don't double-post. This is essential if you combine queue retries with posting. Whether the package exposes header injection via an options argument or you need Http::withHeaders() upstream depends on the version — verify in src/.
Media uploads are two steps. POST /api/v2/media returns an attachment id (and may return 202 while processing — poll GET /api/v1/media/:id until ready), then pass media_ids[] in the status payload. There is no convenience method for this; expect to write it.
Pagination is manual. Listings return a Link header (and max_id/since_id params). The package hands you the decoded body; you must capture headers and loop. For timelines, since_id polling is usually better than full pagination.
No streaming API. The /api/v1/streaming WebSocket endpoint isn't covered. For real-time you need a separate client (e.g. a websocket package or a small Node/Go listener).
Errors are HTTP exceptions. Wrap calls in try/catch (Illuminate\Http\Client\RequestException $e) and branch on $e->response->status() — 401 token, 403 scope, 404 instance, 422 validation (Mastodon's error body is descriptive; log $e->response->json('error')).
Scope mismatch looks like a bug. A 403 on follow or write:statuses usually means the token was minted without that scope. Re-authorize rather than debugging your payload.
Small-package risk. 27 stars, zero dependents, single maintainer. Pin the version in composer.json, keep it behind your own wrapper class, and don't build product-critical logic directly on facade calls. The extension point you actually have is Laravel's HTTP client — bind a custom client into the container if you need retries, logging, or a different transport, rather than patching the package.
How can I help you explore Laravel packages today?