flagrow/flarum-api-client
Flagrow Flarum API Client provides a simple PHP/Laravel wrapper around the Flarum REST API, letting you authenticate and fetch discussions, users, tags, and posts. Clean methods and configurable client make forum integration quick.
The package is a thin Guzzle wrapper around Flarum's JSON:API, so treat it as infrastructure, not a framework. First, install and pin it — the repo is archived and has no maintainer, so an unpinned constraint will eventually bite you:
composer require flagrow/flarum-api-client
# then edit composer.json to an explicit constraint, e.g. "^1.0"
Commit composer.lock. Do not rely on composer update picking up fixes; there will not be any.
Where to look first: the package README's constructor snippet and the FlarumClient class itself. It is small enough to read end-to-end in ten minutes, and you will need to, because the docs are thinner than the code. Identify (a) the constructor signature — base URL plus either credentials or a pre-issued token — and (b) which method performs the /api/token exchange.
Credentials. Flarum's token endpoint is POST {base}/api/token with a JSON body of identification and password, returning {"token": "...", "userId": 1}. Your base URL should therefore be https://community.example.com/api, not the site root. Store secrets in .env and expose them through config/services.php:
// config/services.php
'flarum' => [
'url' => env('FLARUM_URL', 'https://community.example.com/api'),
'username' => env('FLARUM_USERNAME'),
'password' => env('FLARUM_PASSWORD'),
],
Register it as a singleton so a request authenticates once, not once per call:
// app/Providers/AppServiceProvider.php
$this->app->singleton(FlarumClient::class, function () {
$client = new FlarumClient(
config('services.flarum.url'),
config('services.flarum.username'),
config('services.flarum.password'),
);
$client->authenticate(); // method name per README; check the class
return $client;
});
First use case: a read-only "Latest from the community" block on your marketing site, or a scheduled sync that mirrors forum discussions into a local table for search. Both are low-risk: they only need read permissions and a service account. Do not start with write operations — see the permission notes below.
Wrap, don't scatter. Never inject FlarumClient into controllers or Blade views. Put a single App\Services\Flarum\Forum class around it, expose intent-revealing methods (latestDiscussions(int $limit), postReply(int $discussionId, string $body)), and let that class own pagination, mapping, and error translation. When you eventually replace the archived client with raw Http calls, only one file changes.
Cache the token, not the client. Flarum access tokens do not expire by default, so a fresh login on every request is wasted latency and extra load on the forum. Persist the token and reuse it:
$token = Cache::rememberForever('flarum.token', fn () => $client->getToken());
Invalidate the cache key when you receive a 401 (password change, manual revocation), re-authenticate once, and retry the original call exactly once.
Incremental sync with a watermark. For mirroring discussions, keep a synced_at column and walk page[offset] / page[limit], following the links.next URL rather than guessing page numbers. Sort by the newest activity and stop as soon as you hit a record already seen. Run it from a queued command:
Schedule::command('flarum:sync-discussions')->everyFifteenMinutes()->withoutOverlapping();
Chunk the request, not the response. Flarum caps page[limit] server-side (typically 20–50). Asking for 500 silently returns the cap, so a naive loop over count($response) < 500 never terminates. Always advance the offset by the number of records actually returned.
Explicit include. JSON:API responses give you relationships as {type, id} stubs. If you need author names or last-poster avatars, request them: ?include=user,lastPostedUser. Otherwise you will issue an N+1 of individual user lookups and blame the package for being slow.
Write operations belong in jobs. Creating a discussion or posting a reply is a side effect against a live forum. Wrap it in a queued job with $tries = 3, exponential backoff(), and RateLimited middleware. Make the job idempotent by storing the returned post/discussion ID against the local record so a retry after a timeout does not double-post.
Map to your own DTOs. JSON:API gives you data.attributes, data.relationships, and a flattened included array. Write one small mapper per resource type (DiscussionData, PostData) returning typed objects or Eloquent models. This is where you absorb Flarum version differences — for example, tags living in relationships.tags versus a computed attribute.
Polling instead of webhooks. Flarum ships no outbound webhooks; extensions exist but are not guaranteed on your target install. Assume polling and design your scheduler intervals around that until you have confirmed otherwise.
It is archived — version-lock your Flarum too. The client was written against a beta-era JSON:API. Flarum 1.x changed endpoints, response shapes, and error envelopes in places. Before you build on it, confirm the exact Flarum version and hit each endpoint once with curl. If you are on a modern Flarum, seriously evaluate skipping the package and using Laravel's Http client directly:
Http::withHeaders(['Authorization' => 'Token '.$token])
->post("{$base}/discussions", ['data' => ['type' => 'discussions', 'attributes' => [...]]]);
That is ~40 lines of code and gives you Http::fake(), retries, and logging for free.
Token, not Bearer. This is the single most common 401. Flarum's auth scheme is Authorization: Token <token>. If you subclass the client or bypass it, do not reach for Bearer.
The token endpoint wants JSON. POST /api/token with a form-encoded body returns 400/422. If you swap in your own Guzzle call, use 'json' => [...] or set Content-Type: application/json.
A bot account, not a human one. Username/password auth reuses the normal login flow. A human account can be broken by password rotation, email confirmation changes, or 2FA extensions. Create a dedicated account in an appropriate group and document its credentials in your secrets manager.
403s are permissions, not bugs. Every write is evaluated against the authenticated user's group permissions. "Cannot reply to this discussion" is Flarum's tag-level or discussion-level restriction surfacing, not a client failure. Log the errors[].detail from the response body — it is usually explicit.
Trailing slashes produce 404s. https://forum.test/api/ plus a path of /discussions yields //discussions, which some proxies reject. Normalize with rtrim($url, '/') in your config or service class.
Read exceptions, not just status codes. The client throws on non-2xx. Catch the exception, log $e->getResponse()->getBody() (guarded — it may be null on connection errors), and let your service translate it into a domain exception. A network timeout and a validation failure both look like "exception" otherwise.
Testing needs a Guzzle mock, not Http::fake(). The package owns its Guzzle instance, so Laravel's HTTP fake never sees the traffic. Inject a MockHandler/HandlerStack into the client in a test service provider, or point the base URL at a local stub server. If you cannot inject a handler because the client instantiates Guzzle internally, that is your strongest argument for forking.
Set a timeout. Guzzle's default is no timeout. One unreachable forum will hang a queue worker indefinitely. If the constructor does not accept client options, subclass and override the request method to apply ['timeout' => 5, 'connect_timeout' => 2].
Forking is the supported extension path. You cannot wait for upstream. Fork to your org, add retry/logging middleware and a HandlerStack constructor argument, and require it via a VCS repository entry in composer.json. Keep the fork narrowly patched so you can diff against upstream if it ever revives.
How can I help you explore Laravel packages today?