Skip to main content
Version: 1.x

Models and Migrations

At the foundation, any forum revolves around data: users provide discussions, posts, profile information, etc. Our job as forum developers is to provide a great experience for creating, reading, updating, and deleting this data. This article will discuss how Flarum stores and access data. In the next article, we'll follow up on this by explaining how data flows through the API.

Flarum makes use of Laravel's Database component. You should familiarize yourself with it before proceeding, as it is assumed as prior knowledge in the following documentation.

The Big Picture

Before we delve into implementation details, let's define some key concepts.

Migrations allow you to modify the database. If you're adding a new table, defining a new relationship, adding a new column to a table, or making some other DB structural change, you'll need to use a migration.

Models provide a convenient, code-based API for creating, reading, updating, and deleting data. On the backend, they are represented by PHP classes, and are used to interact with the MySQL database. On the frontend, they are represented by JS classes, and are used to interact with the JSON:API, which we'll discuss in the next article.

You can use the CLI to automatically create your model:

$ flarum-cli make backend model
$ flarum-cli make frontend model

Migrations

If we want to use a custom model, or add attributes to an existing one, we will need to modify the database to add tables / columns. We do this via migrations.

Migrations are like version control for your database, allowing you to easily modify Flarum's database schema in a safe way. Flarum's migrations are very similar to Laravel's, although there are some differences.

Migrations live inside a folder suitably named migrations in your extension's directory. Migrations should be named in the format YYYY_MM_DD_HHMMSS_snake_case_description so that they are listed and run in order of creation.

Migration Structure

In Flarum, migration files should return an array with two functions: up and down. The up function is used to add new tables, columns, or indexes to your database, while the down function should reverse these operations. These functions receive an instance of the Laravel schema builder which you can use to alter the database schema:

<?php

use Illuminate\Database\Schema\Builder;

return [
'up' => function (Builder $schema) {
// up migration
},
'down' => function (Builder $schema) {
// down migration
}
];

For common tasks like creating a table, or adding columns to an existing table, Flarum provides some helpers which construct this array for you, and take care of writing the down migration logic while they're at it. These are available as static methods on the Flarum\Database\Migration class.

Migration Lifecycle

Migrations are applied when the extension is enabled for the first time or when it's enabled and there are some outstanding migrations. The executed migrations are logged in the database, and when some are found in the migrations folder of an extension that aren't logged as completed yet, they will be executed.

Migrations can also be manually applied with php flarum migrate which is also needed to update the migrations of an already enabled extension. To undo the changes applied by migrations, you need to click "Purge" next to an extension in the Admin UI, or you need to use the php flarum migrate:reset command. Nothing can break by running php flarum migrate again if you've already migrated - executed migrations will not run again.

There are currently no composer-level hooks for managing migrations at all (i.e. updating an extension with composer update will not run its outstanding migrations).

Creating Tables

To create a table, use the Migration::createTable helper. The createTable helper accepts two arguments. The first is the name of the table, while the second is a Closure which receives a Blueprint object that may be used to define the new table:

use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;

return Migration::createTable('users', function (Blueprint $table) {
$table->increments('id');
});

When creating the table, you may use any of the schema builder's column methods to define the table's columns.

Renaming Tables

To rename an existing database table, use the Migration::renameTable helper:

return Migration::renameTable($from, $to);

Creating/Dropping Columns

To add columns to an existing table, use the Migration::addColumns helper. The addColumns helper accepts two arguments. The first is the name of the table. The second is an array of column definitions, with the key being the column name. The value of each item is an array with the column definitions, as understood by Laravel's Illuminate\Database\Schema\Blueprint::addColumn() method. The first value is the column type, and any other keyed values are passed through to addColumn.

return Migration::addColumns('users', [
'email' => ['string', 'length' => 255, 'nullable' => true],
'discussion_count' => ['integer', 'unsigned' => true]
]);

To drop columns from an existing table, use the Migration::dropColumns helper, which accepts the same arguments as the addColumns helper. Just like when dropping tables, you should specify the full column definitions so that the migration can be rolled back cleanly.

Renaming Columns

To rename columns, use the Migration::renameColumns helper. The renameColumns helper accepts two arguments. The first is the name of the table, while the second is an array of column names to rename:

return Migration::renameColumns('users', ['from' => 'to']);

Default Settings and Permissions

Data migrations are the recommended way to specify default settings and permissions:

return Migration::addSettings([
'foo' => 'bar',
]);

and

use Flarum\Group\Group;

return Migration::addPermissions([
'some.permission' => Group::MODERATOR_ID
]);

Note that this should only be used then adding new permissions or settings. If you use these helpers, and the settings/permissions already exist, you'll end up overriding those settings on all sites where they have been manually configured.

Data Migrations (Advanced)

A migration doesn't have to change database structure: you could use a migration to insert, update, or delete rows in a table. The migration helpers that add defaults for settings/permissions are just one case of this. For instance, you could use migrations to create default instances of a new model your extension adds. Since you have access to the Eloquent Schema Builder, anything is possible (although of course, you should be extremely cautious and test your extension extensively).

Backend Models

With all your snazzy new database tables and columns, you're going to want a way to access the data in both the backend and the frontend. On the backend it's pretty straightforward – you just need to be familiar with Eloquent.

Adding New Models

If you've added a new table, you'll need to set up a new model for it. Rather than extending the Eloquent Model class directly, you should extend Flarum\Database\AbstractModel which provides a bit of extra functionality to allow your models to be extended by other extensions. See the Eloquent docs linked above for examples of what your model class should look like.

Extending Models

If you've added columns to existing tables, they will be accessible on existing models. For example, you can grab data from the users table via the Flarum\User\User model.

If you need to define any attribute casts, or default values on an existing model, you can use the Model extender:

use Flarum\Extend;
use Flarum\User\User;

return [
(new Extend\Model(User::class))
->default('is_alive', true)
->cast('suspended_until', 'datetime')
->cast('is_admin', 'boolean')
];

Relationships

You can also add relationships to existing models using the hasOne, belongsTo, hasMany, belongsToManyand relationship methods on the Model extender. The first argument is the relationship name; the rest of the arguments are passed into the equivalent method on the model, so you can specify the related model name and optionally override table and key names:

    new Extend\Model(User::class)
->hasOne('phone', 'App\Phone', 'foreign_key', 'local_key')
->belongsTo('country', 'App\Country', 'foreign_key', 'other_key')
->hasMany('comment', 'App\Comment', 'foreign_key', 'local_key')
->belongsToMany('role', 'App\Role', 'role_user', 'user_id', 'role_id')

Those 4 should cover the majority of relations, but sometimes, finer-grained customization is needed (e.g. morphMany, morphToMany, and morphedByMany). ANY valid Eloquent relationship is supported by the relationship method:

    new Extend\Model(User::class)
->relationship('mobile', 'App\Phone', function ($user) {
// Return any Eloquent relationship here.
return $user->belongsToMany(Discussion::class, 'recipients')
->withTimestamps()
->wherePivot('removed_at', null);
})

Frontend Models

Flarum provides a simple toolset for working with data in the frontend in the form of frontend models. There's 2 main concepts to be aware of:

  • Model instances are objects that represent a record from the database. You can use their methods to get attributes and relationships of that record, save changes to the record, or delete the record.
  • The Store is a util class that caches all the models we've fetched from the API, links related models together, and provides methods for getting model instances from both the API and the local cache.

Fetching Data

Flarum's frontend contains a local data store which provides an interface to interact with the JSON:API. You can retrieve resource(s) from the API using the find method, which always returns a promise:

// GET /api/discussions?sort=createdAt
app.store.find('discussions', {sort: 'createdAt'}).then(console.log);

// GET /api/discussions/123
app.store.find('discussions', 123).then(console.log);

Once resources have been loaded, they will be cached in the store so you can access them again without hitting the API using the all and getById methods:

const discussions = app.store.all('discussions');
const discussion = app.store.getById('discussions', 123);

The store wraps the raw API resource data in model objects which make it a bit easier to work with. Attributes and relationships can be accessed via pre-defined instance methods:

const id = discussion.id();
const title = discussion.title();
const posts = discussion.posts(); // array of Post models

You can learn more about the store in our API documentation.

Adding New Models

If you have added a new resource type, you will need to define a new model for it. Models must extend the Model class and re-define the resource attributes and relationships:

import Model from 'flarum/common/Model';

export default class Tag extends Model {
title = Model.attribute('title');
createdAt = Model.attribute('createdAt', Model.transformDate);
parent = Model.hasOne('parent');
discussions = Model.hasMany('discussions');
}

You must then register your new model with the store using the frontend Store extender in a new extend.js module:

import Extend from 'flarum/common/extenders';

export default [
new Extend.Store()
.add('tags', Tag),
];
info

Remember to export the extend module from your entry index.js file:

export { default as extend } from './extend';

Extending Models

To add attributes and relationships to existing models, use the Model extender:

  new Extend.Model(Discussion)
.attribute<string>('slug')
.hasOne<User>('user')
.hasMany<Post>('posts')

Saving Resources

To send data back through the API, call the save method on a model instance. This method returns a Promise which resolves with the same model instance:

discussion.save({ title: 'Hello, world!' }).then(console.log);

You can also save relationships by passing them in a relationships key. For has-one relationships, pass a single model instance. For has-many relationships, pass an array of model instances.

user.save({
relationships: {
groups: [
store.getById('groups', 1),
store.getById('groups', 2)
]
}
})

Creating New Resources

To create a new resource, create a new model instance for the resource type using the store's createRecord method, then save it:

const discussion = app.store.createRecord('discussions');

discussion.save({ title: 'Hello, world!' }).then(console.log);

Deleting Resources

To delete a resource, call the delete method on a model instance. This method returns a Promise:

discussion.delete().then(done);

Backend Models vs Frontend Models

Often, backend and frontend models will have similar attributes and relationships. This is a good pattern to follow, but isn't always true.

The attributes and relationships of backend models are based on the database. Each column in the model's table will map to an attribute on the backend model.

The attributes and relationships of frontend models are based on the output of API Serializers. These will be covered more in depth in the next article, but it's worth that a serializer could output all, any, or none of the backend model's attributes, and the names under which they're accessed might be different in the backend and frontend.

Furthermore, when you save a backend model, that data is being written directly to the database. But when you save a frontend model, all you're doing is triggering a request to the API. In the next article, we'll learn how to handle these requests in the backend, so your requested changes are actually reflected in the database.