When we started our community forums, back in 2014, after a detailed research, we decided that XenForo is a mature option which would help us provide a great experience to our community members.
As time passed, this proven to be the right choice, that goes to say how much good research can help.

We started with the 1.x version and we also added quite a number of modifications to it, for example, when a new member registers, we need to make sure a valid product license is used, we also need to check that members have a valid support pack when they post, or to be aware of members renewing their support pack over night.
Remember that we're selling MailWizz via Envato's Marketplaces, so we need to use their API to do all these checks, which adds a layer of complexity.

Add-on requirements

After the migration to XenForo 2.x, we needed to port some of these changes over, so we decided to write a add-on for this, which is the right way to extend XenForo.

We had the following requirements for the new add-on:
1. License check at registration
2. Cron job that runs daily and checks the validity of the support packs
3. When a new thread / reply is created, check the support pack and allow the action only if it's valid

To get started, we created a local development environment using Docker and installed XenForo.
As our IDE, we're using PHPStorm which helps a lot with code completion for XenForo classes, methods, traits, etc.
Once the above was done, we enabled the development mode for XenForo by editing src/config.php and adding:

$config['development']['enabled'] = true;
This step is also described in XenForo's documentation.

According to the docs, you should start creating your add-on from command line by running XenForo's cmd.php file:

php cmd.php xf-addon:create
which will ask you a number of questions in order to create your add-on initial files, which are stored under src/addons.
Here's the output we got for our add-on:
# php cmd.php xf-addon:create
Enter an ID for this add-on: MailWizz/MailWizz
Enter a title:
Enter a version ID. This integer will be used for internal version comparisons. Each release of your add-on should increase this number:  10000
Enter the version string. e.g. 1.0.0 Alpha: 1.0.0
Does this add-on supersede a XenForo 1 add-on? (y/n) n
The addon.json file was successfully written out to /var/www/web/src/addons/MailWizz/MailWizz/addon.json
Does your add-on need a Setup file? (y/n) n
If you change your mind, create a file named Setup.php in /var/www/web/src/addons/MailWizz/MailWizz which should extend AbstractSetup and optionally use one of the StepRunnerX traits.
    
We used the id MailWizz/MailWizz because we want to keep all the functionality created by MailWizz under the same namespace.

We can now navigate in XenForo's admin panel, to Add-ons menu to see and enable our new extension:
XenForo admin - Add-ons list


1. License check at registration

Of course, this is just an empty extension, it doesn't do anything special yet.
We can start by adding functionality to validate the license key at registration.
Our registration page looks like:
Registration page for our forums

Here, the license key field is a user custom field defined in XenForo:
License key custom field

What's great related to XenForo's custom fields is that it allows you to define your own validation callbacks.
This means we can create our own PHP class which XenForo will call to validate the license key.
Inside our newly created add-on at src/addons/MailWizz/MailWizz we can create a new folder called FieldValidator and inside it a new file LicenseKey.php where we will declare our validation class, thus the contents of LicenseKey.php will be:

<?php declare(strict_types=1);

namespace MailWizz\MailWizz\FieldValidator;

use XF\Admin\App as AdminApp;
use XF\CustomField\Definition;
use XF\Entity\UserFieldValue;
use XF\Mvc\Entity\ArrayCollection;

/**
 * Class LicenseKey
 * @package MailWizz\MailWizz\FieldValidator
 */
class LicenseKey
{
    /**
     * @param Definition $definition
     * @param mixed $value
     * @param mixed $error
     *
     * @return bool
     */
    public static function run(Definition $definition, &$value, &$error): bool
    {
        // don't validate in admin area
        if (\XF::app() instanceof AdminApp) {
            return true;
        }

        /** @var ArrayCollection[UserFieldValue] $licenses */
        $licenses = \XF::finder('XF:UserFieldValue')
            ->where('field_id', '=', 'license_key')
            ->where('field_value', '=', $value)
            ->fetch();

        if ($licenses->count() > 0) {
            $error = 'This license is already in use!';
            return false;
        }

        return true;
    }
}
    
Our class actually contains quite a bit more code, but it's not relevant to this example now, thus we don't show it.

In the above code, we're also using XenForo's finder to talk to the database.
All XenForo's entities(UserFieldValue in above example) used by the finder are stored in src/XF/Entity so you can take a look there to see what's available to you. This is where using a good IDE such as PHPStorm will help a lot.

Once the validation class has been created, we can tell XenForo to use it to validate our license key, therefore we go in admin panel and edit the custom field as follows:
License key validation callback Now each time when somebody tries to register, the validation class will be triggered and will validate the license.

2. Cron job that runs daily and checks the validity of the support packs

Next in our list is to add a cron job that runs daily and checks the validity of the support packs.
XenForo makes this relatively easy, remember that earlier, in order to create our add-on, from command line, we ran:

php cmd.php xf-addon:create
Turns out that xf-addon:create is the ID of a console command, and we can create one in our add-on as well.
XenForo uses Symfony's Console component for it's command line commands and will try to autoload commands from the src/addons/MailWizz/MailWizz/Cli/Command folder, therefore, we will create the folders and put the command file inside.
Our file is named HandleLicenseSupport.php and looks like:
<?php declare(strict_types=1);

namespace MailWizz\MailWizz\Cli\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use XF\Entity\User;
use XF\Entity\UserFieldValue;
use XF\Mvc\Entity\ArrayCollection;

/**
 * Class HandleLicenseSupport
 * @package MailWizz\MailWizz\Cli\Command
 */
class HandleLicenseSupport extends Command
{
    /**
     * @inheritDoc
     */
    protected function configure()
    {
        $this
            ->setName('mailwizz:handle-license-support')
            ->setDescription('Update support packs for users');
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     *
     * @return int
     * @throws \XF\PrintableException
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        /** @var ArrayCollection[User] $users */
        $users = \XF::finder('XF:User')
            ->where('is_banned', '=', 0)
            ->order('register_date', 'asc')
            ->fetch();

        /** @var User $user */
        foreach ($users as $index => $user) {
            // ...
        }

        $output->writeln("Done");

        return 0;
    }
}
    
Our class actually contains quite a bit more code, but it's not relevant to this example now, thus we don't show it.

We can see that in the HandleLicenseSupport::configure method we basically register the command, this means we will be able to run it from command line like:
php cmd.php mailwizz:handle-license-support
which makes it easy to be added in a cronjob to run daily, for example:
0 0 * * * /usr/bin/php /var/www/web/cmd.php mailwizz:handle-license-support >/dev/null 2>&1
The HandleLicenseSupport::execute method, as the name says, will get executed when you call the command and will run your code. You can check out Symfony's Console component documentation to see more ways to customize these commands.

3. When a new thread / reply is created, check the support pack and allow the action only if it's valid.

Lastly, we want that each time a member tries to post new content, to validate the support pack and allow the action only if the support pack is valid, otherwise show a meaningful error message.
XenForo has a number of events which are triggered at various execution times throughout the application.
When running in development mode, we can go to Admin > Development > Code event listeners and see exactly which event listeners are used so far and to which events they are attached to.
For our purpose, we will need to add a new event listener as follows:
Add event listener which looks similar to when we added the validation callback for our license key custom field.
The entity_pre_save event is triggered each time a entity is saved, for example when a new thread or post reply is created.
In the Execute callback field we can see that we told XenForo to load the class MailWizz\MailWizz\Listener\CheckAccess and call the run method on it.
The class is located in src/addons/MailWizz/MailWizz/Listener/CheckAccess.php and looks like:

<?php declare(strict_types=1);

namespace MailWizz\MailWizz\Listener;

use XF\Entity\UserFieldValue;
use XF\Mvc\Entity\Entity;
use XF\Entity\Thread;
use XF\Entity\Post;

/**
 * Class CheckAccess
 * @package MailWizz\MailWizz\Listener
 */
class CheckAccess
{
    /**
     * @var bool
     */
    public static bool $handled = false;

    /**
     * @param Entity $entity
     */
    public static function run(Entity $entity): void
    {
        if (self::$handled) {
            return;
        }

        $request = \XF::app()->request();
        if (!$request->isPost() && !$request->isXhr()) {
            return;
        }

        $classes = [
            Thread::class,
            Post::class,
        ];

        $found = false;
        foreach ($classes as $class) {
            if ($entity instanceof $class) {
                $found = true;
                break;
            }
        }

        if (!$found) {
            return;
        }

        $visitor = \XF::visitor();
        if (empty($visitor) || empty($visitor->user_id)) {
            return;
        }

        if ($visitor->is_moderator || $visitor->is_admin) {
            return;
        }

        $supportValid = '....';

        if ($supportValid) {
            return;
        }

        $entity->error('Your support has expired, please renew your support pack before posting new content!', null, false);
        self::$handled = true;
    }
}
    
Our class actually contains quite a bit more code, but it's not relevant to this example now, thus we don't show it.

While the above code will be triggered every time when a entity save happens, it does contain a few conditions to optimize it and make sure it gets triggered only when needed, thus the performance impact is negligible.

Add-on redistribution

In order to also redistribute this add-on, we will have to build it, which can be done from command line by running:

php cmd.php xf-addon:build-release MailWizz/MailWizz
The above command will create:
/src/addons/MailWizz/MailWizz/_releases/MailWizz-MailWizz-1.0.0.zip that can be loaded in another XenForo application and get the same functionality we just built here, the only exception being the field validator, which you'd have to add again in the XenForo admin panel.

Final add-on structure

The final add-on structure looks like:
/var/www/web/src/addons/MailWizz/MailWizz
|-- Cli
|   `-- Command
|       `-- HandleLicenseSupport.php
|-- FieldValidator
|   `-- LicenseKey.php
|-- Listener
|   `-- CheckAccess.php
|-- _releases
|   `-- MailWizz-MailWizz-1.0.0.zip
|-- addon.json

About XenForo's developer documentation

While we managed to complete our tasks, we noticed that the current XenForo 2.x developer documentation can do better, you'll find yourself many times browsing the code to get answers because the docs don't have them, this is why having a good IDE helps a lot.

Final thoughts

We recommend to try and build at least a test add-on, it will help you gather insights related to how XenForo works internally, and maybe learn new things that you can use later in your own projects.