flarum/framework
Flarum is a free, open-source forum platform built on a Laravel-style PHP backend with a modern JavaScript frontend. Its fast, extensible architecture offers a clean REST API and a rich extension system, making it easy to launch and customize a beautiful community forum.
Flarum is not a Laravel application, but it is Laravel-flavoured: it pulls in illuminate/database, illuminate/validation, illuminate/events and a container that wraps Laravel's, and its extensions are Composer packages with migrations and service providers. If you know Laravel, the mental model transfers for ~60% of the backend and almost none of the frontend (Mithril.js SPA, no Blade, no Vue/React).
Minimal setup
composer create-project flarum/flarum forum
cd forum
# edit .env: FLARUM_URL, DB_* credentials (MySQL/MariaDB; SQLite works for local dev)
php flarum install # or: php flarum migrate, then visit / to run the installer
php flarum serve # local dev on :8000; in prod point nginx at public/index.php
Then log in at /admin. The php flarum binary is your artisan: list, info, migrate, cache:clear, assets:publish, extension:enable vendor/name.
Where to look first
vendor/flarum/core/src/ — read Extend/ (the extender classes) before anything else. Extenders are the public API of the framework.vendor/flarum/core/src/Api/Controller/ and Api/Serializer/ — every endpoint is one controller + one serializer, easy to copy.vendor/flarum/core/migrations/ — the schema you'll be extending.extend.php at the site root — the lightest place to prototype a listener or route before committing to a package.First use case worth doing: a small extension that adds a custom API endpoint and a custom Post attribute, then surfaces it in the forum UI. It exercises routes, models, serializers, settings, permissions and the JS extension surface in one pass — after that, 90% of real work is variations on it.
acme/reports/
├── composer.json # "extra": { "flarum-extension": { "title": "...", "category": "feature" } }
├── extend.php # returns an array of extender objects
├── migrations/ # auto-discovered, Laravel-style migrations
├── locale/en.yml
├── src/ # PSR-4, namespaced Acme\Reports\
└── js/src/forum/index.ts, js/src/admin/index.ts, js/package.json
extend.php is a declarative manifest — resist the urge to write a fat service provider:
use Flarum\Extend;
use Flarum\Post\Event\Posted;
return [
(new Extend\Routes('api'))
->get('/acme/reports', 'acme.reports.index', Controllers\ReportIndexController::class),
(new Extend\Event())
->listen(Posted::class, Listeners\ScorePost::class),
(new Extend\Model(Post::class))
->cast('acme_score', 'integer'),
(new Extend\Settings())
->default('acme.reports.threshold', 3)
->serializeToForum('acmeThreshold', 'acme.reports.threshold', 'intval'),
(new Extend\Locales(__DIR__.'/locale')),
];
The pattern is rigid, which is a feature — copy an existing pair and change the model:
class ReportIndexController extends AbstractListController
{
protected $serializer = ReportSerializer::class;
protected $limit = 20;
protected function data(ServerRequestInterface $request, Document $document)
{
return Report::query()->where('status', 'open')->paginate(20);
}
}
The serializer must explicitly expose every attribute/relationship; anything you forget is silently absent from the response. Authorisation goes through Flarum\User\AssertPermissionTrait / Gate with a Policy class registered via (new Extend\Policy())->modelPolicy(Report::class, ReportPolicy::class), mirrored into the admin UI with (new Extend\Frontend('admin')) + app.extensionData.for('acme-reports').registerPermission(...).
Standard Illuminate\Database\Schema builders, wrapped:
return [
'up' => fn (Builder $schema) => $schema->create('acme_reports', function (Blueprint $t) {
$t->increments('id');
$t->unsignedInteger('post_id')->index();
$t->string('status')->default('open');
$t->timestamps();
}),
'down' => fn (Builder $schema) => $schema->drop('acme_reports'),
];
Models extend Flarum\Database\AbstractModel — Eloquent relationships, casts, scopes and $dates all work. Cross-extension relationships should be registered through (new Extend\Model(Post::class))->belongsTo(...) rather than edited into core.
Mithril + extend()/override() against the class registry:
import { extend } from 'flarum/common/extend';
import IndexPage from 'flarum/forum/components/IndexPage';
extend(IndexPage.prototype, 'sidebarItems', function (items) {
items.add('acme', <AcmeWidget threshold={app.forum.attribute('acmeThreshold')} />, 5);
});
extend = wrap a method and mutate its output (default choice); override = replace it (fragile, breaks on upgrade). Read settings from app.forum.attribute() / app.data.settings, never hardcode.
Do not share the database. Authenticate over the API with Authorization: Token <key> (keys generated in Admin → API Keys) and talk JSON:API. For SSO, add an OAuth/OIDC extension and keep Flarum as the identity consumer; for outbound events, listen to domain events (Posted, UserRegistered) and dispatch to your Laravel app via HTTP or a shared Redis/queue bridge. Anything long-running should be a php flarum console command ((new Extend\Console())->command(...)) driven by cron — Flarum has no built-in queue.
Use flarum/testing's integration TestCase; it boots the real app with only the extensions you enable, which is the fastest way to catch extenders that break on upgrade:
protected function setUp(): void
{
parent::setUp();
$this->extend('acme/reports');
$this->prepareDatabase(['acme_reports' => []]);
}
routes/web.php, no make:* generators. Routing is FastRoute, middleware is PSR-15, views are Mithril. Don't reach for view() — it doesn't exist.extend.php, run php flarum cache:clear or you will debug a ghost for twenty minutes.public/assets must be writable. Assets are compiled at runtime; in production always php flarum assets:publish as part of deploy, after composer install --no-dev and php flarum migrate.getDefaultAttributes() entry or a missing ->fields() whitelist — not a database problem.extend.php is authoritative, service providers are escape hatches. If you find yourself registering routes/events manually inside a provider, you're fighting the framework and will break on minor upgrades.extra.flarum-extension and the Composer name drift apart; php flarum info listing an extension as disabled after a failed boot usually means a fatal in an extender or a missing migration.storage/logs/, not storage/logs/laravel.log. Turn up verbosity with FLARUM_DEBUG=true in .env (or 'debug' => true in root config.php) — the default production error page is deliberately opaque.flarum/core: ^1.0 silently won't enable. Constrain tightly and test with the integration suite before bumping.assertCan, the data is public. Audit every controller you add.override(). Overrides are the single largest source of "works until 2.x" breakage in the ecosystem; wrap and mutate instead.How can I help you explore Laravel packages today?