Jay GajjarSenior Software Engineer

Perfex CRM module development: build a custom module step by step (with hooks)

Perfex CRM module development, step by step: init file headers, activation hooks, menus, staff permissions, tables, language files and safe module upgrades.

By Jay Gajjar11 min read

Perfex CRM module development means packaging your feature as a self-contained folder in modules/ that plugs into Perfex through hooks, so you never touch core files and your work survives updates. A working module needs four things: an init file with a header comment, an activation hook that creates its tables, a menu item behind a staff permission, and controllers, models and views that follow CodeIgniter conventions. This Perfex CRM module tutorial builds a real one step by step, checked against the official Perfex module docs (opens in a new tab) and the Perfex source, and it flags the places where the docs and the current code disagree.

Quick answer: the Perfex CRM module development checklist

  1. Switch the install to development mode so you see errors and deprecation warnings.
  2. Create modules/your_module/your_module.php. The folder name and the init file name must match.
  3. Add the header comment with at least Module Name, then Version and Requires at least.
  4. Register an activation hook that creates tables with db_prefix() and default options with add_option().
  5. Register menu items and staff capabilities on the admin_init action.
  6. Add a controller that extends AdminController, a model that extends App_Model and views that use init_head() and init_tail().
  7. Hook into core events with hooks()->add_action() and hooks()->add_filter().
  8. Register language files and wrap every label in _l().
  9. For every new version, bump the Version header and add a migration file.

The rest of this article explains each step with code you can copy.

How Perfex CRM modules work

Perfex CRM is built on CodeIgniter 3. A module is a mini CodeIgniter package: it can have its own controllers, models, views, helpers, libraries, language files and migrations, and Perfex loads it when the module is active. On top of CodeIgniter, Perfex adds a WordPress-style hook system (hooks()->add_action, hooks()->add_filter) and a set of helper functions for menus, permissions, options and language files.

The module system arrived in version 2.3.0, and the developer docs are valid from 2.3.2. The current release at the time of writing is 3.4.1 (March 2026), a security and maintenance release that also added PHP 8.5 compatibility and fixed an unauthenticated remote code execution flaw in a bundled library. If you are building on an older install, update it first. Writing a module against an outdated core means testing twice.

The reason to build a module instead of editing core files is simple: an update overwrites core files. The Perfex team says plainly that maintaining a module across versions is the author's job, and that breaking changes can arrive as the codebase becomes more modular. A module keeps your changes in one folder you control, and hooks keep the contact points with core small and easy to check after each update.

If your need is small (one filter, one extra validation rule), you may not need a module at all. Perfex also loads application/helpers/my_functions_helper.php, which is the documented place for small hook-based tweaks. Anything with its own screen, table or permission belongs in a module.

Set up a safe development environment

Good Perfex CRM module development starts on a local or staging copy with a recent database dump, never on the live CRM. Then turn on development mode. Open index.php in the Perfex root and change:

define('ENVIRONMENT', 'production');

to:

define('ENVIRONMENT', 'development');

Errors now show on screen and are logged in application/logs. Remember to switch back before you deploy. Put the module folder under Git from the first commit. As you will see below, uninstalling a module deletes its folder from the server, so the repository is your only reliable copy.

Step 1: create the module skeleton and init file

Pick a lowercase system name with underscores, for example service_requests. Perfex only lists the module if the folder and the init file share that exact name. A layout that grows well:

modules/service_requests/
├── service_requests.php        # init file with headers and hooks
├── install.php                 # creates tables and options
├── index.html                  # empty, blocks directory listing
├── controllers/Service_requests.php
├── models/Service_requests_model.php
├── views/manage.php
├── language/english/service_requests_lang.php
└── migrations/                 # added from version 1.1.0 onward

Add an empty index.html to every folder, as the module security guide (opens in a new tab) asks. Now the init file:

<?php

defined('BASEPATH') or exit('No direct script access allowed');

/*
Module Name: Service Requests
Description: Log and track customer service requests with their own staff permissions.
Version: 1.0.0
Requires at least: 3.1.*
Author: Your Name
Author URI: https://example.com
*/

define('SERVICE_REQUESTS_MODULE', 'service_requests');

register_activation_hook(SERVICE_REQUESTS_MODULE, 'service_requests_activate');
register_uninstall_hook(SERVICE_REQUESTS_MODULE, 'service_requests_uninstall');
register_language_files(SERVICE_REQUESTS_MODULE, [SERVICE_REQUESTS_MODULE]);

hooks()->add_action('admin_init', 'service_requests_register_permissions');
hooks()->add_action('admin_init', 'service_requests_register_menu');
hooks()->add_action('after_client_created', 'service_requests_after_client_created');

function service_requests_activate()
{
    $CI = &get_instance();
    require_once __DIR__ . '/install.php';
}

The available module file headers (opens in a new tab) are Module Name (required), Module URI, Version, Description, Author, Author URI and Requires at least. I set Requires at least to 3.1 because the code below uses staff_cant(), which arrived in 3.1.0. Save the file, open Setup > Modules, and the module appears in the list.

Prefix every function and class with your module name. All modules share one global namespace, and two modules that both declare get_status() will take the whole CRM down with a fatal error.

Step 2: activation, deactivation and uninstall hooks

register_activation_hook(), register_deactivation_hook() and register_uninstall_hook() are thin wrappers. Each one attaches your function to an action named after the module, such as activate_service_requests_module. Here is install.php, which runs inside the activation function, so $CI is in scope:

<?php

defined('BASEPATH') or exit('No direct script access allowed');

if (!$CI->db->table_exists(db_prefix() . 'service_requests')) {
    $CI->db->query('CREATE TABLE `' . db_prefix() . "service_requests` (
        `id` int(11) NOT NULL AUTO_INCREMENT,
        `client_id` int(11) NOT NULL,
        `subject` varchar(191) NOT NULL,
        `description` text NULL,
        `status` varchar(30) NOT NULL DEFAULT 'open',
        `created_by` int(11) NOT NULL,
        `created_at` datetime NOT NULL,
        PRIMARY KEY (`id`),
        KEY `client_id` (`client_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=" . $CI->db->char_set . ';');
}

add_option('service_requests_default_status', 'open');

Three details matter here.

Always use db_prefix(). Since 2.3.0 an admin can change the table prefix from the default tbl. Hard-code tblservice_requests and the module breaks on any install that changed it.

Make the install script idempotent. In the Perfex source, the activation action fires every time someone activates the module, not only the first time. Deactivate and reactivate, and install.php runs again. The table_exists() check and add_option() (which skips options that already exist) make a second run harmless.

Treat uninstall as destructive. In the Perfex source, uninstall only works on an inactive module. It removes the module's row from the modules table, runs your cleanup, and then deletes the module's folder from the server. One detail the docs do not mention: if the module root contains an uninstall.php, Perfex includes that file instead of firing your registered uninstall hook. Pick one approach, not both. With the hook approach:

function service_requests_uninstall()
{
    $CI = &get_instance();
    $CI->db->query('DROP TABLE IF EXISTS `' . db_prefix() . 'service_requests`');
    delete_option('service_requests_default_status');
}

Ask the client before you drop data on uninstall. For business records, I often keep the table and document how to remove it by hand, because "uninstall" in a CRM is sometimes clicked by someone who only meant "turn off".

Step 3: add menu items and staff permissions

Menus and permissions both register on the admin_init action. Capabilities first, using register_staff_capabilities() from the staff capabilities guide (opens in a new tab):

function service_requests_register_permissions()
{
    $capabilities = [];
    $capabilities['capabilities'] = [
        'view'   => _l('permission_view') . '(' . _l('permission_global') . ')',
        'create' => _l('permission_create'),
        'edit'   => _l('permission_edit'),
        'delete' => _l('permission_delete'),
    ];

    register_staff_capabilities('service_requests', $capabilities, _l('service_requests'));
}

function service_requests_register_menu()
{
    if (staff_cant('view', 'service_requests')) {
        return;
    }

    $CI = &get_instance();
    $CI->app_menu->add_sidebar_menu_item('service-requests', [
        'name'     => _l('service_requests'),
        'href'     => admin_url('service_requests'),
        'position' => 42,
        'icon'     => 'fa fa-life-ring',
    ]);
}

The new capabilities appear under Setup > Staff > (member) > Permissions. Administrators pass every check automatically, so test with a non-admin staff account.

The position value decides where the item sits. The menu items guide (opens in a new tab) lists the defaults, including Customers 5, Sales 10, Projects 30, Tasks 35, Tickets 40, Leads 45 and Utilities 55, so 42 lands between Tickets and Leads. To add a child under an existing parent, use add_sidebar_children_item('utilities', [...]) with a unique slug. Client-area items use the clients_init action and add_theme_menu_item().

If you are reading older tutorials: has_permission() was deprecated in 3.1.0 in favor of staff_can() and staff_cant(). Always pass the feature name as the second argument, because capability names like view exist in many features.

Step 4: build a CRUD screen with a controller, model and view

A module controller (opens in a new tab) that extends AdminController only runs for logged-in staff. When the controller has the same name as the module, its URL is admin/service_requests.

<?php

defined('BASEPATH') or exit('No direct script access allowed');

class Service_requests extends AdminController
{
    public function __construct()
    {
        parent::__construct();
        $this->load->model('service_requests_model');
    }

    public function index()
    {
        if (staff_cant('view', 'service_requests')) {
            access_denied('service_requests');
        }

        $data['title']    = _l('service_requests');
        $data['requests'] = $this->service_requests_model->get_all();
        $this->load->view('manage', $data);
    }

    public function create()
    {
        if (staff_cant('create', 'service_requests')) {
            access_denied('service_requests');
        }

        if ($this->input->post()) {
            $this->service_requests_model->add([
                'client_id'   => (int) $this->input->post('client_id'),
                'subject'     => $this->input->post('subject'),
                'description' => $this->input->post('description'),
            ]);

            set_alert('success', _l('added_successfully', _l('service_request')));
        }

        redirect(admin_url('service_requests'));
    }
}

The model extends App_Model and owns every query:

<?php

defined('BASEPATH') or exit('No direct script access allowed');

class Service_requests_model extends App_Model
{
    public function get_all()
    {
        $this->db->order_by('created_at', 'desc');

        return $this->db->get(db_prefix() . 'service_requests')->result_array();
    }

    public function add(array $data)
    {
        $data['status']     = get_option('service_requests_default_status');
        $data['created_by'] = get_staff_user_id();
        $data['created_at'] = date('Y-m-d H:i:s');

        $this->db->insert(db_prefix() . 'service_requests', $data);

        return $this->db->insert_id();
    }
}

The controller whitelists the fields it passes to the model instead of handing over the whole $this->input->post() array. That stops a crafted form from writing status or created_by directly.

For the admin view (opens in a new tab), Perfex has no template engine. Call init_head() at the top and init_tail() at the bottom to get the header, sidebar and assets, and open forms with form_open() so the CSRF token is included:

<?php defined('BASEPATH') or exit('No direct script access allowed'); ?>
<?php init_head(); ?>
<div id="wrapper">
  <div class="content">
    <div class="panel_s">
      <div class="panel-body">
        <h4><?php echo _l('service_requests'); ?></h4>
        <?php echo form_open(admin_url('service_requests/create')); ?>
          <input type="hidden" name="client_id" value="1">
          <input type="text" name="subject" class="form-control" required>
          <textarea name="description" class="form-control"></textarea>
          <button type="submit" class="btn btn-primary"><?php echo _l('submit'); ?></button>
        <?php echo form_close(); ?>
        <ul>
          <?php foreach ($requests as $request) { ?>
            <li><?php echo html_escape($request['subject']); ?></li>
          <?php } ?>
        </ul>
      </div>
    </div>
  </div>
</div>
<?php init_tail(); ?>
</body>
</html>

In a real module the client field would be a customer dropdown and the list would be a data table, but the moving parts are the same.

Step 5: use Perfex CRM hooks for actions and filters

Perfex CRM hooks are how a module reacts to core events without editing core. Actions run your code when something happens. Filters let you change data before Perfex uses it, and your callback must return the value.

hooks()->add_action($tag, $callback, $priority = 10, $accepted_args = 1);
hooks()->add_filter($tag, $callback, $priority = 10, $accepted_args = 1);

Here is the place where the docs lag the code. The action hooks page (opens in a new tab) still uses after_client_added in its example. In the Perfex 3.x source, after_client_added is deprecated since 2.9.4 and replaced by after_client_created, which receives an array instead of a bare ID. In development mode the old name triggers a deprecation notice. Use the new one:

function service_requests_after_client_created(array $payload): void
{
    $CI = &get_instance();
    $CI->db->insert(db_prefix() . 'service_requests', [
        'client_id'   => $payload['id'],
        'subject'     => 'Onboarding call',
        'description' => null,
        'status'      => 'open',
        'created_by'  => get_staff_user_id(),
        'created_at'  => date('Y-m-d H:i:s'),
    ]);
}

Filters work the same way. This one, from the docs, rewrites data before a client is saved:

function service_requests_before_client_added(array $data): array
{
    if ($data['company'] === 'Test') {
        $data['company'] = '';
    }

    return $data;
}

hooks()->add_filter('before_client_added', 'service_requests_before_client_added');

There is no complete hook reference, so the reliable way to find hooks is the source. Search application/ for hooks()->do_action( and hooks()->apply_filters( near the feature you want to extend, and read what arguments are passed. Your module can also fire its own hooks with hooks()->do_action('service_request_created', $id), which lets a later module extend yours without editing it.

Step 6: add language files

Create modules/service_requests/language/english/service_requests_lang.php. The language folder name must already exist in application/language.

<?php

$lang['service_requests'] = 'Service requests';
$lang['service_request']  = 'Service request';

The init file already calls register_language_files(), which loads the file and lets users override your strings through custom_lang.php. Fetch strings with _l('service_requests'). Prefix every key with the module name so you never collide with a core key, and copy every file into each additional language folder you support.

Step 7: ship updates with module migrations

Perfex compares the Version header in your init file with the version it stored on activation. When they differ, it asks the admin to run a database upgrade, which uses CodeIgniter's sequential migrations from your migrations/ folder. To release 1.1.0, bump the header and add migrations/110_version_110.php:

<?php

defined('BASEPATH') or exit('No direct script access allowed');

class Migration_Version_110 extends App_module_migration
{
    public function up()
    {
        $CI = &get_instance();

        if (!$CI->db->field_exists('priority', db_prefix() . 'service_requests')) {
            $CI->db->query('ALTER TABLE `' . db_prefix() . 'service_requests`
                ADD `priority` varchar(20) NOT NULL DEFAULT \'normal\'');
        }
    }
}

The upgrade guide (opens in a new tab) sets three rules worth memorizing. Add a migration for every version bump, even an empty one, or the upgrade prompt never clears. There is no downgrade, so only write up(). And install.php must always contain the latest schema, because migrations only run on existing installs, while new installs get their tables from the activation hook.

Security checklist for Perfex modules

  • Start every PHP file with defined('BASEPATH') or exit('No direct script access allowed');.
  • Read input through $this->input->post() and $this->input->get(), and build queries with the query builder so values are escaped.
  • Check staff_can() or staff_cant() in every controller method, not only in the menu. Hiding a link does not protect a URL.
  • Open forms with form_open(). jQuery $.post and $.ajax calls get the CSRF token automatically, while other HTTP libraries must send it by hand.
  • For incoming webhooks (a payment gateway, WhatsApp, a form tool), exclude only that route from CSRF with modules/your_module/config/csrf_exclude_uris.php (2.9.0 and later), and verify the sender's signature inside the controller instead.
  • Escape output with html_escape() and whitelist fields before inserts and updates.
  • Never store API keys in code. Use options, and keep secrets out of client-facing views.

Common Perfex module errors and fixes

Symptom Likely cause Fix
Module missing from Setup > Modules Folder and init file names differ, or no Module Name header Rename to match, add the header
404 on admin/your_module Controller file or class name not capitalized like Service_requests Match file name, class name and module name
"The action you have requested is not allowed" Form posted without a CSRF token Use form_open(), or exclude a webhook URI
Fatal "cannot redeclare function" Two modules or a helper use the same function name Prefix every function with the module name
Database upgrade prompt never clears Version bumped without a matching migration file Add NNN_version_NNN.php, even with an empty up()
New column missing on fresh installs Change added to a migration only Also add it to install.php
Errors after a PHP upgrade Old module code using removed PHP features Run it in development mode and fix the logged notices

The last row is common after hosts move to PHP 8.x. The same patterns show up in plain CodeIgniter apps, covered in CodeIgniter 3 PHP 8 errors and fixes and the broader PHP 8 upgrade checklist.

Building for one company vs selling on CodeCanyon

Perfex explicitly allows authors to sell modules on CodeCanyon, and since Envato Market is non-exclusive, on their own site too. Selling changes the job. You support installs you have never seen, on PHP and Perfex versions you did not choose, with other modules that may fire the same hooks. That means stricter prefixing, a migration for every release, a tested upgrade path from every past version, and no assumptions about the table prefix or language.

A module built for one company can be simpler and more specific. It can assume one PHP version, one set of other modules and one business process. Most of the Perfex CRM module development I see is this kind: a client needs a GST-ready invoice tweak, a custom approval step or an integration with a system they already run, and a focused module is cheaper to build and maintain than a general product. If you would rather hand the build to someone who does this regularly, see my Perfex CRM customization and module development service.

Frequently asked questions

Will my custom Perfex module survive Perfex CRM updates?

Usually yes, if it only talks to core through hooks, helpers and its own tables. Updates overwrite core files, not your module folder. Still test every Perfex release on staging first, because the Perfex team states that breaking changes can happen and that module authors must keep modules compatible.

Do I need to know CodeIgniter to build a Perfex CRM module?

You need the basics of CodeIgniter 3: controllers, models, views, the loader and the query builder. Perfex adds hooks and helpers on top, but routing, input handling and database access are plain CodeIgniter. A developer comfortable with CodeIgniter development can be productive in Perfex within a day or two of reading the source.

Where should small Perfex customizations go if not in a module?

Small hook-based tweaks can live in application/helpers/my_functions_helper.php, which the Perfex docs name as the place for custom hook code. As soon as a change needs its own table, screen, permission or settings, move it into a module so it can be activated, versioned and removed cleanly.

Can a Perfex module add a payment gateway?

Yes. Perfex provides register_payment_gateway($id, $module) for this, and the docs have a dedicated module as payment gateway (opens in a new tab) guide. You still need to verify the gateway's webhook signature in your controller and exclude only that callback URI from CSRF.

How long does it take to build a Perfex CRM custom module?

It depends on the number of screens, tables, permissions and integrations, not on Perfex itself. A single CRUD screen with permissions is small. A module that syncs with an external accounting system and handles failures is a different size of project. Write down the screens, roles and edge cases first, and the estimate follows from that.

If you have a Perfex module in mind and want it built cleanly, with hooks and migrations done properly, I can help through my Perfex CRM customization service.