Writing a Flarum Extension: Building a Custom Field

    Bruno Skvorc
    Share

    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.