Writing a Flarum Extension: Building a Custom Field

Share this article

Writing a Flarum Extension: Building a Custom Field

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.

Adding an extension: add web3 address, Approval, BBCode

Hello, forum message in the developer console

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 use extend here instead of override because we want to extend the UserCard 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 its infoitems, which is an instance of itemlist. The methods of this type are documented here.
  • Model is the entity shared with the back end, representing a database model, and User is a specific instance of that Model.

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.

Hello extension shown on 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.

User card with input alongside the devtools view

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:

The request shown in devtools

Note ℹ: you can find out which objects are available on the global app object, like the session object, by console.loging 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.

The web3address in the console

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
      />
    );
  }
// ...

The web3 input displayed on the user card

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.

Editing option

Editing the username

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.

Web3 address added to user card

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.

Frequently Asked Questions (FAQs) about Building a Custom Field in Flarum Extension

What is a Flarum extension and why is it important?

A Flarum extension is a software add-on designed to add extra features to the Flarum forum software. It’s important because it allows developers to customize and enhance the functionality of their forums. For instance, an extension can add a new feature, modify an existing one, or change the way something works in the forum. This flexibility makes Flarum a powerful tool for creating unique and engaging online communities.

How do I create a custom field in a Flarum extension?

Creating a custom field in a Flarum extension involves several steps. First, you need to set up your development environment. Then, you create a new extension using Composer, which is a tool for dependency management in PHP. After that, you need to create a migration file to add your custom field to the database. Finally, you write the code to add the field to the user interface and handle the data input and output.

What is Composer and how is it used in Flarum extension development?

Composer is a tool for dependency management in PHP. It allows you to declare the libraries your project depends on and it will manage (install/update) them for you. In Flarum extension development, Composer is used to create a new extension and to manage its dependencies.

What is a migration file in Flarum extension development?

A migration file in Flarum extension development is a script that makes changes to the database structure. It’s used to create, modify, or delete database tables and fields. When you create a custom field in a Flarum extension, you need to create a migration file to add that field to the database.

How do I add a custom field to the user interface in a Flarum extension?

Adding a custom field to the user interface in a Flarum extension involves writing JavaScript code. You need to extend the appropriate component and add your field to the view. Then, you need to handle the data input and output by overriding the appropriate methods.

How do I handle data input and output for a custom field in a Flarum extension?

Handling data input and output for a custom field in a Flarum extension involves writing PHP code. You need to override the appropriate methods in the User model to get and set the value of your custom field. Then, you need to extend the UserSerializer to include your field in the API response.

How do I test a Flarum extension?

Testing a Flarum extension involves installing it in a Flarum installation and checking if it works as expected. You can use automated testing tools like PHPUnit for unit testing and Behat for behavior-driven testing.

How do I distribute a Flarum extension?

Distributing a Flarum extension involves packaging it and making it available for download. You can distribute it through the Packagist, which is the default Composer package repository, or through other channels like GitHub.

What are some common challenges in Flarum extension development?

Some common challenges in Flarum extension development include dealing with dependencies, understanding the Flarum core code, and ensuring compatibility with different versions of Flarum and other extensions.

Where can I find resources and support for Flarum extension development?

You can find resources and support for Flarum extension development in the official Flarum documentation, the Flarum community forum, and various online tutorials and guides. You can also look at the source code of existing extensions to learn from their implementation.

Bruno SkvorcBruno Skvorc
View Author

Bruno is a blockchain developer and technical educator at the Web3 Foundation, the foundation that's building the next generation of the free people's internet. He runs two newsletters you should subscribe to if you're interested in Web3.0: Dot Leap covers ecosystem and tech development of Web3, and NFT Review covers the evolution of the non-fungible token (digital collectibles) ecosystem inside this emerging new web. His current passion project is RMRK.app, the most advanced NFT system in the world, which allows NFTs to own other NFTs, NFTs to react to emotion, NFTs to be governed democratically, and NFTs to be multiple things at once.

custom fieldsflarumflarum extensionforumforum softwarejavascriptPHPReact
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week