Flarum is incredibly fast, extensible, free and open-source forum software. It has been in development since 2014 and is nearing the end of its beta phase.
In this tutorial, we’ll go through the process of adding a custom field to a user account. This custom field will be settable from a user’s profile page by the user only, but also manually editable by an administrator. The full and final source code of this extension is on GitHub.
🙏 Huge thanks to @askvortsov for review and assistance in doing this The Right Way™.
What We’re Adding
We’ll allow users to add their Web3 address into their profile. A Web3 address is a user’s cryptographic identity in the Web3 ecosystem – the public part of a public-private keypair (like SSH) representing one’s blockchain-compatible account.
Note ℹ: the Web3 ecosystem is a new internet of decentralized hosting, self-owned data, and censorship-resistant communication. For a primer on Web3, please see this 15 minute talk at FOSDEM.
Even if you’re not interested in Web3, this tutorial will be useful. This first part of the tutorial will show you how to build a custom field for a user, and the second part will add the actual Web3 address in a cryptographically secure way.
Prerequisites
We assume you have NodeJS installed and on a recent enough version (12.16+ is OK), and Composer available globally. For your sanity, we also recommend using Yarn instead of npm. PHP, MySQL, and other requirements for Flarum are assumed to be present and running properly.
In the examples below, we’re hosting the local Flarum copy at ubikforum.test
, which some screenshots might reflect.
Please also make sure that your forum is in debug
mode by setting the appropriate value in config.php
:
<?php return array(
'debug' => true,
'database' => // ...
New Extension
We start a new extension by running the Friends of Flarum boilerplate wizard inside a newly created packages
folder in our local Flarum installation’s root folder:
# cd into your flarum folder
mkdir packages & cd packages
npx @friendsofflarum/create-flarum-extension web3address
Important ⚠: remember to follow best deployment practices and ignore the packages
folder if you’re pushing this Flarum folder to a repo from which you’re deploying your live version.
Fill out the inputs provided by the wizard:
✔ Admin CSS & JS … no
✔ Forum CSS & JS … yes
✔ Locale … yes
✔ Javascript … yes
✔ CSS … yes
Note ℹ: you’ll want to set Admin CSS & JS
to yes
if you have plans to work with settings and/or permissions, like letting only some people modify their web3address
attribute or similar. In this case, we don’t need it.
Keep in mind that, due to a bug, the generator doesn’t support numbers in the package name or namespace. As such, it’s best to rename those values after the generation is complete. (For example, you can’t use web3address
as the name, but blockchain
is fine.)
We also need to compile the JavaScript. It’s best to leave it running in watch mode, so that it’s automatically recompiled on file changes and you can quickly check changes while developing:
cd packages/web3address
cd js
yarn && yarn dev
Note ℹ: you’ll want to leave this running in a terminal tab and execute the rest of the commands in another tab. The dev
command activates an always-on task that will occupy the current terminal session.
We then install our newly created extension:
composer config repositories.0 path "packages/*"
composer require swader/blockchain @dev
The first line will tell Composer that it should look for packages we install in the packages
subfolder, and, if it doesn’t find them, to default to Packagist.org.
The second line installs our newly created extension. Once it’s in, we can load our forum’s admin interface, activate the extension, and check the console on the forum’s front end for a “Hello world” message. If it’s there, the new extension works.
Extending
When building extensions, you’re always extending the raw Flarum underneath. These extensions are defined in your extension’s extend.php
file with various extenders being “categories” of possible extension points you can hook into. We’ll modify this file later.
Keep in mind that the forum itself has an extend.php
file in its root folder as well. This file is useful for minor, root-level extensions that your users can do on your instance of Flarum without having to write a full extension around the functionality. If you want to share what you’ve built with others, or distribute it to alternative copies of Flarum, an extension is the way to go.
The extend.php
file currently looks like this:
<?php
namespace Swader\Web3Address;
use Flarum\Extend;
return [
(new Extend\Frontend('forum'))
->js(__DIR__ . '/js/dist/forum.js')
->css(__DIR__ . '/resources/less/forum.less'),
new Extend\Locales(__DIR__ . '/resources/locale')
];
If you were extending the admin
UI as well, there would be another Frontend
block referencing admin
instead of forum
. As it stands, we’re only adding new JS and styles to the forum’s front end and, optionally, localizing our extension’s UI elements, so these are the parts that get extended.
This file is where we’ll define alternative routes and some listeners, as you’ll see later.
JavaScript
First, let’s add the UI placeholders. We’ll edit the file js/src/forum/index.js
.
In the beginning, our index.js
file contains only this:
app.initializers.add("swader/web3address", () => {
console.log("[swader/web3address] Hello, forum!");
});
The initializers.add
call makes the application append the JavaScript specified here to the rest of the JavaScript in the app. The execution flow is as follows:
- all PHP code loads
- main JS code loads
- extension JS code loads in order of activation in the admin UI
If a certain extension depends on another, Flarum will automatically order their dependencies as long as they are specified as each other’s dependency in their relevant composer.json
files.
Let’s change the file’s contents to:
import { extend } from "flarum/extend";
import UserCard from "flarum/components/UserCard";
import Model from "flarum/Model";
import User from "flarum/models/User";
app.initializers.add("swader/web3address", () => {
User.prototype.web3address = Model.attribute("web3address");
extend(UserCard.prototype, "infoItems", function (items) {
items.add("web3address", <p>{this.attrs.user.web3address()}</p>);
if (app.session.user === this.attrs.user) {
items.add("web3paragraph", <p>Hello extension</p>);
}
});
});
flarum/extend
is a collection of utilities for extending or overriding certain UI elements and JS components in Flarum’s front-end code. We useextend
here instead ofoverride
because we want to extend theUserCard
element with a new item.override
would instead completely replace it with our implementation. More information on the differences is available here.UserCard
is the user info card on one’s profile. This component has itsinfoitems
, which is an instance ofitemlist
. The methods of this type are documented here.Model
is the entity shared with the back end, representing a database model, andUser
is a specific instance of thatModel
.
In the code above, we tell the JS to extend the User
prototype with a new field: web3address
, and we set it to be a model attribute called web3address
by calling the attribute
method of Model
. Then we want to extend
the UserCard’s item list by adding the web3address
value as output, and also if the profile viewer is also the profile owner, by adding a web3paragraph
that’s just a paragraph with “Hello extension” inside it.
Important ⚠: extend can only mutate output if the output is mutable (for example, an object or array, and not a number/string). Use override to completely modify output regardless of type. More info here.
Reloading your user’s profile in the forum will show the “Hello extension” paragraph added to the items in the User Card.
Let’s make this a custom component. Create src/forum/components/Web3Field.js
(you’ll need to create the components
folder).
Give it the following code:
import Component from "flarum/Component";
export default class Web3Field extends Component {
view() {
return (
<input
className="FormControl"
onblur={this.saveValue.bind(this)}
placeholder="Your Web3 address"
/>
);
}
saveValue(e) {
console.log("Save");
}
}
The Component
import is a base component of Flarum that we want to extend to build our own. It’s a wrapped Mithril component with some jQuery sprinkled in for ease of use. We export
it because we want to use it in our index.js
file, so we’ll need to import it there. We then define a view
method which tells Flarum what to show as the Component’s content. In our case, it’s just an input
field which calls the function saveValue
when it loses focus (that is, you navigate away from it). Refreshing the forum should reveal that this already works.
Front-end models come by default with a save
method. We can get the current user model, which is an instance of User
, through app.session.user
. We can then change the saveValue
method on our component:
saveValue(e) {
const user = app.session.user;
user
.save({
web3address: "Some value that's different",
})
.then(() => console.log("Saved"));
}
Calling save
on a user
object will send a request to the UpdateUserController
on the PHP side:
Note ℹ: you can find out which objects are available on the global app
object, like the session
object, by console.log
ing it when the forum is open.
Migration
We want to store each user’s web3address
in the database, so we’ll need to add a column to the users
table. We can do this by creating a migration. Create a new folder migrations
in the root folder of the extension and inside it 2020_11_30_000000_add_web3address_to_user.php
with:
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
if (!$schema->hasColumn('users', 'web3address')) {
$schema->table('users', function (Blueprint $table) use ($schema) {
$table->string('web3address', 100)->index();
});
}
},
'down' => function (Builder $schema) {
$schema->table('users', function (Blueprint $table) use ($schema) {
$table->dropColumn('web3address');
});
}
];
This is a standard way of adding fields through migrations. More info here.
Note ℹ: the name of the file is a convention: YYYY_MM_DD_HHMMSS_name_of_what_youre_doing.php
which helps with sequential execution of migrations. With this name format, they are be easily sortable which is important for migrations that might depend on one another. In theory, even something like 000000001_web3address.php
would work, but would go against convention. In Flarum, a migration file’s name must have an underscore in it.
Then, in the root folder of your forum’s installation, run php flarum migrate
to run this migration.
Listeners
Flarum works through listeners: they listen for some events, and then react to them by invoking certain PHP classes.
Serializing
Whenever a user model is updated through app.session.user.save
, the model is serialized after being saved on the PHP end and sent back to the front end. In this serialized form, it is easily parsed and turned into a usable JS object for the UI to show and interact with. Serialization of a PHP object — in particular after it being saved — is one such event we can listen for.
We’ll write a listener which reacts to serialization and adds the new web3address
field to the model in flight, so that the front end becomes aware of this field and can display it in the UI.
Create /src/Listener/AddUserWeb3AddressAttribute.php
(create the directory if it does not exist):
<?php
namespace Swader\Web3Address\Listener;
use Flarum\Api\Event\Serializing;
use Flarum\Api\Serializer\UserSerializer;
class AddUserWeb3AddressAttribute
{
public function handle(Serializing $event)
{
if ($event->isSerializer(UserSerializer::class)) {
$event->attributes += [
'web3address' => $event->model->web3address,
];
}
}
}
We import the Serializing
event so we can read information from it, and the UserSerializer
to check the type of the event (there are many serializations happening at all times, so we need to be specific). Then, if the serialization that’s happening is indeed user serialization, we add a new attribute to our event and give it the value of the web3address
field in the database attached to the model currently being serialized.
Now, why are we adding an attribute to the $event
and not some instance of user? Because the $event
object’s attributes
property is a reference (pointer) to the attributes
object of the model being serialized — in this case, a user.
Before this kicks in, it needs to be registered in our extension’s extend.php
. Add the following line after the last comma in the list in that file:
(new Extend\Event())->listen(Serializing::class, AddUserWeb3AddressAttribute::class),
In the same file, we also need to import the two classes we reference:
use Flarum\Api\Event\Serializing;
use Swader\Web3Address\Listener\AddUserWeb3AddressAttribute;
If we now refresh the forum and try to call our save function again by moving into the Web3 address field and out of it (remember, it triggers on blur), the console log will reveal that we do get web3address
back.
We can display this in our input field by editing the Web3Field.js
component:
// ...
export default class Web3Field extends Component {
view() {
return (
<input
className="FormControl"
onblur={this.saveValue.bind(this)}
placeholder="Your Web3 address"
value={app.session.user.data.attributes.web3address} // <-- this is new
/>
);
}
// ...
Now let’s handle the saving part.
Saving
When the JavaScript code we wrote calls app.session.user.save
, the UpdateUserController
class is invoked.
Note ℹ: you can find out how these JS models are connected to corresponding controllers by looking at Model.js#163, which leads to Model.js#225 and the type is returned by the serializer as part of the JSON:API protocol: each serializer has a type (such as BasicDiscussionSerializer.php#20).
This UpdateUserController
class saves the core-defined fields of this model (everything except our newly added web3address
field), and then dispatches Saving
as an event so any extensions that might need to piggyback on it can react to it.
We’ll write a listener to react to this event in out extension’s /src/Listener/SaveUserWeb3Address.php
:
<?php
namespace Swader\Web3Address\Listener;
use Flarum\User\Event\Saving;
use Illuminate\Support\Arr;
class SaveUserWeb3Address
{
public function handle(Saving $event)
{
$user = $event->user;
$data = $event->data;
$actor = $event->actor;
$isSelf = $actor->id === $user->id;
$canEdit = $actor->can('edit', $user);
$attributes = Arr::get($data, 'attributes', []);
if (isset($attributes['web3address'])) {
if (!$isSelf) {
$actor->assertPermission($canEdit);
}
$user->web3address = $attributes['web3address'];
$user->save();
}
}
}
To be aware of the Event, we import it. To trivially use some array functionality, we add Illuminate’s Arr
helper. The $event
instance that this listener reacts to will be passed into it as an argument and will contain the target of the event (user
), the actor who initiated this event (the logged-in user, represented as a User
object), and any data attached to the event.
Our save
function on the JavaScript side contains this:
.save({
web3address: "Some value that's different",
})
This is what $data
is going to contain.
Let’s change the value to the actual value of the input field:
saveValue(e) {
const user = app.session.user;
user
.save({
web3address: e.target.value,
})
.then(() => console.log("Saved"));
}
This listener also needs to be registered in extend.php
. Our final version of this file is now as follows:
namespace Swader\Web3Address;
use Flarum\Extend;
use Flarum\Api\Event\Serializing;
use Flarum\User\Event\Saving;
use Swader\Web3Address\Listener\AddUserWeb3AddressAttribute;
use Swader\Web3Address\Listener\SaveUserWeb3Address;
return [
(new Extend\Frontend('forum'))
->js(__DIR__ . '/js/dist/forum.js')
->css(__DIR__ . '/resources/less/forum.less'),
new Extend\Locales(__DIR__ . '/resources/locale'),
(new Extend\Event())
->listen(Serializing::class, AddUserWeb3AddressAttribute::class)
->listen(Saving::class, SaveUserWeb3Address::class),
];
Changing the field’s value will now auto-save it in the database. Refreshing the screen will have the field auto-populated with a value. Visiting someone else’s profile will reveal their Web3 address listed. Finally, let’s allow admins to edit other people’s address values.
Admin control
Every admin has an “Edit User” dialog at their fingertips. This control is in the Controls
menu in someone’s profile. By default, this allows an admin to change a user’s Username and the groups they belong to.
It’s relatively simple to extend this dialog with an additional web3address
option. In index.js
under our app.initializers
function, let’s add this:
extend(EditUserModal.prototype, "oninit", function () {
this.web3address = Stream(this.attrs.user.web3address());
});
extend(EditUserModal.prototype, "fields", function (items) {
items.add(
"web3address",
<div className="Form-group">
<label>
Web3 Address
</label>
<input
className="FormControl"
bidi={this.web3address}
/>
</div>,
1
);
});
extend(EditUserModal.prototype, "data", function (data) {
const user = this.attrs.user;
if (this.web3address() !== user.web3address()) {
data.web3address = this.web3address();
}
});
We’ll also need to import the two new components — Stream
(that’s Stream), and EditUserModal
:
import Stream from "flarum/utils/Stream";
import EditUserModal from "flarum/components/EditUserModal";
The first extend
registers the web3address
propery in the edit popup component instance. The second extend
adds a new field into the popup. The last value in add
is the priority; higher means closer to start of list, so we put this at the end of the form by setting it to 1. The bidi
param is a bidirectional bind for Mithril, which makes it so that any edit of the field’s value immediately updates the same value in the component, live. Finally, the data
extension makes sure the data object that’ll get sent to the back end contains the newly added web3address
property.
Conclusion
Our custom field works, is settable by users, and is editable by administrators of the forum.
Up to this point, the extension can be modified to add any custom field to your users. Just change the field and filenames to match your field (or fields!) and it’ll work. Don’t forget to tell the world what you’ve built!
In a follow-up post, we’ll look at how to cryptographically verify ownership of someone’s web3 address before adding it to their profile.
Got any feedback about this post? Need something clarified? Feel free to contact me on Twitter — @bitfalls.