Module Development Guide

Build custom modules for CRM — the PHP MVC framework powering CRM.

1. Introduction

CRM is the PHP MVC framework at the core of CRM. It provides an action-based controller system, Smarty 2.6 templating, an ORM layer, and a suite of UI component builders.

Two Module Types

TypeDescriptionAccess
Visitor-Facing (public)Pages accessible without login (landing pages, forms, etc.)No auth required
Registered / PrivilegedAdmin or secured user-facing pages within the CRM dashboardRequires login or admin role

File Roles

FilePurpose
modules/mymodule.phpController — registers action handlers via pb_on_action()
themes/default/mymodule.tplSmarty template — renders the HTML output
themes/default/mymodule.cssModule-specific CSS (loaded via pb_add_cssfile())
themes/default/mymodule.jsModule-specific JavaScript (loaded via pb_add_jsfile())
modules/preload/mymodule-models.phpModel classes, auto-loaded by the framework

URL Pattern

text
index.php?m=<module>&d=<action>

Examples:
  ?m=polls&d=view           → calls pb_on_action('view', ...) in modules/polls.php
  ?m=polls-admin&d=admin    → admin panel
  ?m=polls-admin&d=install  → install action

2. Your First Module

Create two files: a controller and a Smarty template. No registration needed for public-facing modules.

File Structure

text
httpdocs/
├── modules/
│   └── polls.php            ← Controller
└── themes/
    └── default/
        └── polls.tpl        ← Template

Controller: modules/polls.php

php
<?php
// modules/polls.php — Public-facing polls module

pb_on_action('view', function () {
    pb_title('Community Polls');
    pb_template('polls');

    // Pass data to the template via the $m array
    $polls = getsqlarray("
        SELECT id, title, total_votes
        FROM polls
        WHERE id_company = " . TCompany::CurrentCompany()->ID() . "
          AND active = 1
        ORDER BY id DESC
        LIMIT 20
    ");

    pb_set_tpl_var('polls', $polls);
    pb_set_tpl_var('total', count($polls));
});

pb_on_action('vote', function () {
    $pollId = PB::POST('poll_id')->AsAbsInt();
    $answer = PB::POST('answer_id')->AsAbsInt();

    if (!$pollId || !$answer) {
        pb_set_action('view');   // redirect to 'view' action
        return;
    }

    execsql("UPDATE poll_answers SET votes = votes + 1 WHERE id = $answer AND id_poll = $pollId");
    execsql("UPDATE polls SET total_votes = total_votes + 1 WHERE id = $pollId");

    pb_notify('Your vote has been recorded!');
    pb_set_action('view');
});

Template: themes/default/polls.tpl

smarty
{* themes/default/polls.tpl *}

{if $m.action eq 'view'}
    {include file="block_begin.tpl"}

    <h2>Community Polls ({$m.total})</h2>

    {foreach from=$m.polls item=poll}
        <div class="poll-card">
            <h3>{$poll.title}</h3>
            <p>{$poll.total_votes} votes cast</p>
            <a href="index.php?m=polls&d=view&id={$poll.id}">View Poll</a>
        </div>
    {/foreach}

    {include file="block_end.tpl"}
{/if}
Reserved template variable names: Do not use action, mode, or panel as keys in pb_set_tpl_var() — these are set by the framework and will be overwritten.

Access via URL

text
http://localhost:8090/index.php?m=polls&d=view

3. Admin Modules

Admin modules must implement three mandatory controller actions: admin, install, and uninstall. They also need a module name comment in the file header for the Module Management UI to recognize them.

Required File Header

php
/* * Module Name: Polls Management */

Full Admin Module Skeleton

php
<?php
/* * Module Name: Polls Management */

use PB\Access\PBAccess;
use PB\Common\Redirect;
use PB\Core\PBURL;
use PB\UI\UI;

// ── admin ───────────────────────────────────────────────────
// The main management page shown in the CRM dashboard
pb_on_action('admin', function () {
    PBAccess::AdminRequired();   // 403 if not super admin

    pb_title('Polls Management');
    pb_template('polls-admin');

    $list = new PollList();
    $list->SetFilterCompany(TCompany::CurrentCompany()->ID());
    pb_pagination_init_for_list($list);
    $list->Load();

    $ui = new UI();
    PB::BreadCrumbs()->Add('Polls', PBURL::CurrentModule('admin'));

    $buttons = new Buttons();
    $buttons->Button('Add Poll', PBURL::CurrentModule('add-poll'));
    $ui->Add($buttons);

    $grid = new Grid();
    $grid->AddCol('title', 'Poll Question');
    $grid->AddCol('votes', 'Votes');
    $grid->AddEdit();
    $grid->AddDelete();

    foreach ($list->EachItem() as $poll) {
        $grid->Label('title', $poll->Title());
        $grid->Label('votes', $poll->FieldIntValue('total_votes'));
        $grid->URL('title', PBURL::CurrentModule('edit-poll', ['id' => $poll->ID()]));
        $grid->NewRow();
    }

    if ($grid->RowCount() === 0) {
        $grid->SingleLineLabel('No polls yet. Click Add Poll to create one.');
    }

    $ui->Add($grid);
    $ui->AddPaginatorForList($list);
    $ui->Output(true);
});

// ── install ─────────────────────────────────────────────────
// Runs once when admin clicks "Install" in Module Management UI
pb_on_action('install', function () {
    PBAccess::AdminRequired();

    // Register the module so it appears in the nav
    pb_register_module('polls-admin', 'Polls Management');
    // Register the preload file so Poll/PollList classes are auto-loaded
    pb_register_autoload('polls-models');

    // Create the DB schema
    execsql("CREATE TABLE IF NOT EXISTS polls (
        id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        id_company  INT UNSIGNED NOT NULL DEFAULT 0,
        title       VARCHAR(255) NOT NULL DEFAULT '',
        total_votes INT UNSIGNED NOT NULL DEFAULT 0,
        active      TINYINT(1)   NOT NULL DEFAULT 1,
        timeadded   INT UNSIGNED NOT NULL DEFAULT 0,
        INDEX idx_company (id_company)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    execsql("CREATE TABLE IF NOT EXISTS poll_answers (
        id       INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        id_poll  INT UNSIGNED NOT NULL DEFAULT 0,
        title    VARCHAR(255) NOT NULL DEFAULT '',
        votes    INT UNSIGNED NOT NULL DEFAULT 0,
        INDEX idx_poll (id_poll)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    Redirect::Now(PBURL::AdminModulesManagement());
});

// ── uninstall ───────────────────────────────────────────────
pb_on_action('uninstall', function () {
    PBAccess::AdminRequired();

    pb_unregister_module('polls-admin');
    pb_unregister_autoload('polls-models');

    execsql("DROP TABLE IF EXISTS polls");
    execsql("DROP TABLE IF EXISTS poll_answers");

    Redirect::Now(PBURL::AdminModulesManagement());
});

// ── add-poll (show form) ─────────────────────────────────────
pb_on_action('add-poll', function () {
    PBAccess::AdminRequired();
    pb_title('Add Poll');
    pb_template('polls-admin');

    $ui = new UI();
    PB::BreadCrumbs()
        ->Add('Polls', PBURL::CurrentModule('admin'))
        ->AddCurrent('Add Poll');

    $f = new POSTForm(PBURL::CurrentModule('create-poll'));
    $f->AddText('question', 'Poll Question', true)->SetFocus();
    $f->AddText('answer1', 'Answer 1', true);
    $f->AddText('answer2', 'Answer 2', true);
    $f->AddText('answer3', 'Answer 3', false);
    $f->AddText('answer4', 'Answer 4', false);
    $f->AddSubmit('Create Poll');

    if (!PB::Requests()->POSTAsArray()) {
        $f->LoadValuesArray(PB::Requests()->POSTAsArray());
    }

    $ui->Add($f);
    $ui->Output(true);
});

// ── create-poll (POST handler) ────────────────────────────────
pb_on_action('create-poll', function () {
    PBAccess::AdminRequired();

    $question = PB::POST('question');
    $answers  = [
        PB::POST('answer1')->AsString(),
        PB::POST('answer2')->AsString(),
        PB::POST('answer3')->AsString(),
        PB::POST('answer4')->AsString(),
    ];

    if ($question->isEmpty()) {
        PB::Errors()->AddError('Poll question is required');
        PB::Errors()->SwitchActionOnError('add-poll');
        return;
    }

    // Create the poll
    $pollId = Poll::Create(
        $question->AsString(),
        TCompany::CurrentCompany()->ID()
    );

    // Create answers
    foreach ($answers as $answerText) {
        if ($answerText !== '') {
            PollAnswer::Create($pollId, $answerText);
        }
    }

    pb_notify('Poll created successfully!');
    Redirect::Now(PBURL::CurrentModule('admin'));
});

Installation Steps

  1. Place modules/polls-admin.php in the modules directory
  2. Place model preload file at modules/preload/polls-models.php
  3. Log in as super admin
  4. Navigate to Admin → Modules → Manage Modules
  5. Find "Polls Management" in the list → click Install

4. Core Functions Reference

FunctionDescription
pb_on_action($action, $fn)Register a controller callback for a named action
pb_title($title)Set the page <title> tag
pb_template($name)Specify template file to render (without .tpl extension)
pb_set_tpl_var($key, $value)Pass a variable to the template — accessible as $m.key
pb_set_action($action)Switch the active action (used to redirect to another action without HTTP redirect)
pb_notify($message)Queue a success popup notification shown on next page load
pb_add_cssfile($path)Include a CSS file from the theme directory
pb_add_jsfile($path)Include a JS file from the theme directory
pb_pagination_init_for_list($list)Initialize paginator and set Limit()/Offset() on a PBDBList from query params
pb_register_module($module, $name)Register module in the CMS module list (called in install action)
pb_unregister_module($module)Unregister module from the CMS module list
pb_register_autoload($file)Register a preload file so its classes are auto-loaded on every request
pb_unregister_autoload($file)Unregister a preload file
pb_current_module()Returns the current module name string (value of ?m=)
execsql($sql)Execute a write SQL query (INSERT, UPDATE, DELETE, DDL)
getsqlfield($sql)Execute SQL and return a single scalar value
getsqlarray($sql)Execute SQL and return an array of associative row arrays
insertsql($table, $data)Insert an associative array as a row and return the new auto-increment ID
dbescape($val)Escape a value for safe inclusion in a SQL string
Do NOT use: dbquery(), dbresult(), or dbfetch() — these functions do not exist in this codebase and will cause Fatal errors. Always use execsql(), getsqlfield(), getsqlarray(), and insertsql().

5. Request Handling

Use PB::POST($name) and PB::GET($name) to read request parameters. Both return a RequestParam object with safe accessor methods — never read from $_POST or $_GET directly.

RequestParam Methods

MethodReturnsDescription
isEmpty()boolTrue if the param is absent or empty string
Exists()boolTrue if the param key is present in the request
AsString()stringRaw string value (not escaped)
EscapedString()stringMySQL-escaped string — use in raw SQL queries
AsInt()intInteger value (can be negative)
AsAbsInt()intAbsolute (non-negative) integer — use for IDs
AsFloat()floatFloating point value
AsBool()boolBoolean — truthy string values return true
AsArray()arrayArray value (for multi-select inputs)
isStringEqual($str)boolCase-sensitive string comparison

Bulk Request Access

MethodDescription
PB::Requests()->POSTAsArray()Returns all POST data as an associative array (useful for $form->LoadValuesArray())
PB::Requests()->GETAsArray()Returns all GET parameters as an associative array

Form Validation Example

php
pb_on_action('create-poll', function () {
    PBAccess::AdminRequired();

    $question  = PB::POST('question');
    $answer1   = PB::POST('answer1');
    $answer2   = PB::POST('answer2');

    // Validate required fields
    if ($question->isEmpty()) {
        PB::Errors()->AddError('Poll question is required');
    }
    if ($answer1->isEmpty()) {
        PB::Errors()->AddError('At least one answer is required');
    }

    // If validation fails, re-show the form
    if (PB::Errors()->HasErrors()) {
        PB::Errors()->SwitchActionOnError('add-poll');
        return;
    }

    // All good — persist
    $id = insertsql('polls', [
        'title'      => dbescape($question->AsString()),
        'id_company' => TCompany::CurrentCompany()->ID(),
        'timeadded'  => time(),
        'active'     => 1,
    ]);

    pb_notify('Poll created!');
    Redirect::Now(PBURL::CurrentModule('admin'));
});

6. UI Components

The UI component system renders consistent, theme-compatible HTML. All components are added to a UI container and output via $ui->Output(true).

UI Container

php
$ui = new UI();
$ui->NotificationInfo('Showing all polls for this company.');
$ui->Add($someComponent);
$ui->Add($anotherComponent);
$ui->Output(true);   // true = render immediately

Form (POSTForm)

php
// POSTForm sends data via HTTP POST
$f = new POSTForm(PBURL::CurrentModule('create-poll'));

$f->AddText('question', 'Poll Question', true)->SetFocus();    // required=true
$f->AddText('answer1',  'Answer 1',      true);
$f->AddText('answer2',  'Answer 2',      false);               // required=false
$f->AddTextarea('description', 'Description', false);
$f->AddSelect('category', 'Category', false, [
    'general'  => 'General',
    'product'  => 'Product',
    'service'  => 'Service',
]);
$f->AddCheckbox('active', 'Active', true);                     // checked by default
$f->AddSubmit('Create Poll');

// Repopulate form after a validation error
$f->LoadValuesArray(PB::Requests()->POSTAsArray());

$ui->Add($f);

Buttons

php
$b = new Buttons();
$b->Button('Add Poll', PBURL::CurrentModule('add-poll'));
$b->Button('Export',   PBURL::CurrentModule('export'), 'secondary');
$ui->Add($b);

Grid (Data Table)

php
$grid = new Grid();
$grid->AddCol('title', 'Poll Question');
$grid->AddCol('votes', 'Total Votes');
$grid->AddEdit();       // adds an Edit column
$grid->AddDelete();     // adds a Delete column

foreach ($list->EachItem() as $poll) {
    $grid->Label('title', $poll->Title());
    $grid->Label('votes', $poll->FieldIntValue('total_votes'));

    // Make the title clickable
    $grid->URL('title', PBURL::CurrentModule('edit-poll', ['id' => $poll->ID()]));

    // Link edit/delete icons
    $grid->EditURL(PBURL::CurrentModule('edit-poll',   ['id' => $poll->ID()]));
    $grid->DeleteURL(PBURL::CurrentModule('delete-poll', ['id' => $poll->ID()]));

    $grid->NewRow();   // finalise this row and start the next
}

if ($grid->RowCount() === 0) {
    $grid->SingleLineLabel('No polls found.');
}

$ui->Add($grid);
$ui->AddPaginatorForList($list);   // render pagination links

Notification Methods

MethodColorDescription
UI::NotificationSuccess($msg)GreenAction completed successfully
UI::NotificationError($msg)RedAction failed
UI::NotificationWarning($msg)AmberAction completed with caveats
UI::NotificationInfo($msg)BlueInformational message

Error Collection

php
// Adding errors
PB::Errors()->AddError('Field X is required');

// Checking errors
if (PB::Errors()->HasErrors()) {
    // Switch to the form action to re-display with errors
    PB::Errors()->SwitchActionOnError('add-poll');
    return;
}

// Displaying errors in a UI container
PB::Errors()->DisplayUIErrors($ui);

// Getting error string (for logging)
$msg = PB::Errors()->GetErrorsAsString();

Breadcrumbs

php
PB::BreadCrumbs()
    ->Add('Polls', PBURL::CurrentModule('admin'))
    ->AddCurrent('Edit Poll');

7. ORM — Models (PBDBObject)

Model classes extend PBDBObject. They represent a single row in a DB table and provide CRUD methods.

Required Overrides

MethodReturnsDescription
TableName()stringThe database table name
FieldNameForID()stringThe primary key column name (usually 'id')
FieldNameForTitle()stringThe column used as a display title by Title()

Field Accessors (Read)

MethodDescription
FieldIntValue($field)Read an integer column value
FieldStringValue($field)Read a string column value
FieldFloatValue($field)Read a float column value
FieldBoolValue($field)Read a boolean column value (TINYINT 0/1)
FieldMoneyValue($field)Read and format a money column value
ID()Return the row's primary key value
Title()Return the value of FieldNameForTitle()

Write Methods

MethodDescription
CreateWithParams([...])Static — insert a new row and return the new model instance
UpdateValues([...])Update specific columns on this row
Increment($field)Atomically increment an integer column by 1
SetTitle($title)Update the title column
Remove()Delete this row from the DB (calls OnRemoveBeforeStart() first)

Lifecycle Hook

php
protected function OnRemoveBeforeStart(): void {
    // Called before Remove() deletes the row.
    // Use to clean up child records:
    execsql("DELETE FROM poll_answers WHERE id_poll = " . $this->ID());
}

Complete Poll Model Example

php
<?php
// modules/preload/polls-models.php

/**
 * @method static self initSingleton($id)
 * @method static self UsingCache($id)
 * @method static self initNotExistent()
 */
class Poll extends PBDBObject
{
    public function TableName(): string       { return 'polls'; }
    public function FieldNameForID(): string   { return 'id'; }
    public function FieldNameForTitle(): string { return 'title'; }

    // Convenience accessor
    public function TotalVotes(): int { return $this->FieldIntValue('total_votes'); }
    public function IsActive(): bool  { return $this->FieldBoolValue('active'); }

    /** Factory method */
    public static function Create(string $title, int $companyId): int {
        $poll = self::CreateWithParams([
            'title'      => $title,
            'id_company' => $companyId,
            'timeadded'  => time(),
            'active'     => 1,
        ]);
        return $poll->ID();
    }

    /** Clean up child records before deleting the poll */
    protected function OnRemoveBeforeStart(): void {
        execsql("DELETE FROM poll_answers WHERE id_poll = " . $this->ID());
    }
}

/**
 * @method static self initSingleton($id)
 * @method static self UsingCache($id)
 */
class PollAnswer extends PBDBObject
{
    public function TableName(): string        { return 'poll_answers'; }
    public function FieldNameForID(): string    { return 'id'; }
    public function FieldNameForTitle(): string { return 'title'; }

    public function Votes(): int { return $this->FieldIntValue('votes'); }

    public static function Create(int $pollId, string $title): int {
        $answer = self::CreateWithParams([
            'id_poll' => $pollId,
            'title'   => $title,
            'votes'   => 0,
        ]);
        return $answer->ID();
    }
}

8. ORM — Lists (PBDBList)

List classes extend PBDBList and provide a query-builder interface for fetching multiple rows. They are the primary way to load collections of model objects.

Required Override

php
class PollList extends PBDBList
{
    public function ObjectClassName(): string { return Poll::class; }
}

Filter Methods

MethodDescription
SetFilterFieldIntValue($field, $val)WHERE field = int
SetFilterFieldStringValue($field, $val)WHERE field = 'string' (escaped)
SetFilterFieldLikeValue($field, $val)WHERE field LIKE '%val%'
SetFilterCompany($id)WHERE id_company = N
SetFilterCompanyAndDefault($id)WHERE (id_company = N OR id_company = 0)
DisableNoFiltersBlocker()Required for queries without any filters (fetches all rows)

Sorting

MethodDescription
OrderByID($asc)Sort by primary key (pass true for ASC, false for DESC)
OrderByTitle($asc)Sort by the title field
OrderByField($field, $asc)Sort by any column name

Pagination

php
$list = new PollList();
$list->SetFilterCompany(TCompany::CurrentCompany()->ID());

// Manual pagination
$list->Limit(20);
$list->Offset(PB::GET('from')->AsAbsInt());   // ?from=0, ?from=20, etc.

// Or use the automatic paginator helper
pb_pagination_init_for_list($list);

$list->Load();   // execute the query

Retrieval

php
// Iterate all results
foreach ($list->EachItem() as $poll) {
    echo $poll->Title();
}

// Access by index
$first = $list->FirstItem();
$last  = $list->LastItem();
$nth   = $list->Item(2);

// Lookup by primary key
$specific = $list->GetItemWithID(42);

Streaming Large Result Sets

php
// Use Open()/Fetch() for large data sets to avoid loading all rows into memory
$list->Open();
while ($poll = $list->Fetch()) {
    // process $poll
}
$list->Close();

Complete List Example

php
class PollList extends PBDBList
{
    public function ObjectClassName(): string { return Poll::class; }

    public function SetFilterByActive(bool $active): self {
        $this->SetFilterFieldIntValue('active', $active ? 1 : 0);
        return $this;
    }

    public function SetFilterByCompany(int $companyId): self {
        $this->SetFilterCompany($companyId);
        return $this;
    }
}

class PollAnswersList extends PBDBList
{
    public function ObjectClassName(): string { return PollAnswer::class; }

    public function SetFilterByPoll(int $pollId): self {
        $this->SetFilterFieldIntValue('id_poll', $pollId);
        return $this;
    }
}

// Usage in a controller
$list = new PollList();
$list->SetFilterByCompany(TCompany::CurrentCompany()->ID())
     ->SetFilterByActive(true);
$list->OrderByID(false);   // newest first
pb_pagination_init_for_list($list);
$list->Load();

9. URL Helpers

FunctionDescription
PBURL::CurrentModule($action, $params, $returnto)URL for an action in the current module, with optional extra params and returnto URL
PBURL::Module($module, $action, $params)URL for an action in any module
PBURL::Current()The current page URL
PBURL::CurrentWithAction($action)Current URL with a different action value
PBURL::AdminModulesManagement()URL to the Module Management admin page

Examples

php
// Current module URLs
PBURL::CurrentModule('admin')
// → index.php?m=polls-admin&d=admin

PBURL::CurrentModule('edit-poll', ['id' => 42])
// → index.php?m=polls-admin&d=edit-poll&id=42

PBURL::CurrentModule('create-poll', [], PBURL::CurrentModule('admin'))
// → index.php?m=polls-admin&d=create-poll&returnto=...

// Cross-module URL
PBURL::Module('customerdetails', 'view', ['id' => 1234])
// → index.php?m=customerdetails&d=view&id=1234

// Useful in breadcrumbs and buttons
$b = new Buttons();
$b->Button('Back to Polls', PBURL::CurrentModule('admin'));
$b->Button('Edit', PBURL::CurrentModule('edit-poll', ['id' => $poll->ID()]));

10. Redirects

MethodDescription
Redirect::Now($url)Immediately redirect the browser to a URL (sends HTTP 302, stops execution)
Redirect::ReturnToNow()Redirect to the URL in the returnto query parameter
Redirect::NowReturnToOr($default)Redirect to returnto if set, otherwise use the default URL

Examples

php
// After saving a form, go to the admin list
pb_notify('Poll saved!');
Redirect::Now(PBURL::CurrentModule('admin'));

// After install, go to module management
Redirect::Now(PBURL::AdminModulesManagement());

// Return to whatever page linked here, or fall back to admin
Redirect::NowReturnToOr(PBURL::CurrentModule('admin'));

11. Access Control

MethodDescription
PBAccess::AdminRequired()Returns 403 if the current user is not a super admin (user_status=3 or id=1)
PBAccess::EnforceLogin()Returns 403 if the user is not logged in
PBAccess::UserRequired($user_id)Returns 403 if the current user's ID does not match the given ID
PB::Show404IfEmpty($object)Returns a 404 page if the given object is null / not found
PB::User()->ID()Returns the current logged-in user's ID
PB::isLoggedIn()Returns true if a user session is active
PB::isAdministrator()Returns true if the current user is a super admin

Examples

php
// Require super admin for install/uninstall
pb_on_action('install', function () {
    PBAccess::AdminRequired();
    // ...
});

// Require any logged-in user for user-facing pages
pb_on_action('view', function () {
    PBAccess::EnforceLogin();
    // ...
});

// Load a model and 404 if not found
$poll = Poll::initSingleton(PB::GET('id')->AsAbsInt());
PB::Show404IfEmpty($poll);

// Conditional rendering based on role
if (PB::isAdministrator()) {
    // show admin controls
}

// Show user-specific data
$userId = PB::User()->ID();
isAdministrator() vs isLoggedIn(): PB::isAdministrator() returns true only for user_status=3 or id=1. Regular company users (status=1) are logged in but not admins. Use PB::isLoggedIn() in index.php bootstrap checks — System class is not yet initialized at that point.

12. Templates (Smarty)

Templates use Smarty 2.6.26. Variables are passed via pb_set_tpl_var() and accessed as $m.key in the template.

Core Template Patterns

smarty
{* Check which action is being rendered *}
{if $m.action eq 'view'}
    {include file="block_begin.tpl"}

    <h2>All Polls</h2>

    {* Loop over an array *}
    {foreach from=$m.polls item=poll}
        <div class="poll">
            <h3>{$poll.title}</h3>
            <span>{$poll.total_votes} votes</span>
        </div>
    {foreachelse}
        <p>No polls yet.</p>
    {/foreach}

    {* Pagination — standard include *}
    {include file="paginator.tpl"}

    {include file="block_end.tpl"}

{elseif $m.action eq 'add-poll'}
    {* Different layout for the add form *}
    {include file="block_begin.tpl"}
    {* Form is rendered by the UI component system — no HTML needed here *}
    {include file="block_end.tpl"}
{/if}

Standard Includes

IncludePurpose
{include file="block_begin.tpl"}Opens the main content wrapper, renders breadcrumbs and page title
{include file="block_end.tpl"}Closes the main content wrapper
{include file="paginator.tpl"}Renders pagination links (requires pb_pagination_init_for_list() in controller)
Never use {literal} blocks. Smarty 2.6 has a known bug where {literal} blocks are silently stripped during compilation. Always move inline JavaScript to external .js files loaded via pb_add_jsfile(). If JS must be interleaved with Smarty variables, use {ldelim} and {rdelim} to escape braces.

Reserved $m Keys

Do not use these as keys in pb_set_tpl_var() — they are set by the framework:

text
$m.action   → current action name (value of ?d=)
$m.mode     → current display mode
$m.panel    → active panel identifier

13. Complete Example — Polls Module

A fully working polls module with admin CRUD, voting, and a public-facing page. All three files together form a complete, deployable module.

File 1: modules/polls-admin.php

php
<?php
/* * Module Name: Polls Management */

use PB\Access\PBAccess;
use PB\Common\Redirect;
use PB\Core\PBURL;
use PB\UI\UI;

// ── admin ──────────────────────────────────────────────────────
pb_on_action('admin', function () {
    PBAccess::AdminRequired();
    pb_title('Polls Management');
    pb_template('polls-admin');

    $list = new PollList();
    $list->SetFilterCompany(TCompany::CurrentCompany()->ID());
    $list->OrderByID(false);
    pb_pagination_init_for_list($list);
    $list->Load();

    $ui = new UI();
    PB::BreadCrumbs()->Add('Polls', PBURL::CurrentModule('admin'));

    $b = new Buttons();
    $b->Button('Add Poll', PBURL::CurrentModule('add-poll'));
    $ui->Add($b);

    $grid = new Grid();
    $grid->AddCol('title', 'Question');
    $grid->AddCol('votes', 'Votes');
    $grid->AddCol('status', 'Status');
    $grid->AddEdit();
    $grid->AddDelete();

    foreach ($list->EachItem() as $poll) {
        $grid->Label('title', $poll->Title());
        $grid->Label('votes', $poll->TotalVotes());
        $grid->Label('status', $poll->IsActive() ? 'Active' : 'Inactive');
        $grid->URL('title', PBURL::CurrentModule('edit-poll', ['id' => $poll->ID()]));
        $grid->EditURL(PBURL::CurrentModule('edit-poll', ['id' => $poll->ID()]));
        $grid->DeleteURL(PBURL::CurrentModule('delete-poll', ['id' => $poll->ID()]));
        $grid->NewRow();
    }

    if ($grid->RowCount() === 0) {
        $grid->SingleLineLabel('No polls yet. Click Add Poll to create one.');
    }

    $ui->Add($grid);
    $ui->AddPaginatorForList($list);
    $ui->Output(true);
});

// ── install ─────────────────────────────────────────────────────
pb_on_action('install', function () {
    PBAccess::AdminRequired();

    pb_register_module('polls-admin', 'Polls Management');
    pb_register_autoload('polls-models');

    execsql("CREATE TABLE IF NOT EXISTS polls (
        id          INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        id_company  INT UNSIGNED NOT NULL DEFAULT 0,
        title       VARCHAR(255) NOT NULL DEFAULT '',
        total_votes INT UNSIGNED NOT NULL DEFAULT 0,
        active      TINYINT(1)   NOT NULL DEFAULT 1,
        timeadded   INT UNSIGNED NOT NULL DEFAULT 0,
        INDEX idx_company (id_company)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    execsql("CREATE TABLE IF NOT EXISTS poll_answers (
        id       INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
        id_poll  INT UNSIGNED NOT NULL DEFAULT 0,
        title    VARCHAR(255) NOT NULL DEFAULT '',
        votes    INT UNSIGNED NOT NULL DEFAULT 0,
        INDEX idx_poll (id_poll)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

    Redirect::Now(PBURL::AdminModulesManagement());
});

// ── uninstall ───────────────────────────────────────────────────
pb_on_action('uninstall', function () {
    PBAccess::AdminRequired();

    pb_unregister_module('polls-admin');
    pb_unregister_autoload('polls-models');
    execsql("DROP TABLE IF EXISTS polls");
    execsql("DROP TABLE IF EXISTS poll_answers");

    Redirect::Now(PBURL::AdminModulesManagement());
});

// ── add-poll ────────────────────────────────────────────────────
pb_on_action('add-poll', function () {
    PBAccess::AdminRequired();
    pb_title('Add Poll');
    pb_template('polls-admin');

    $ui = new UI();
    PB::BreadCrumbs()
        ->Add('Polls', PBURL::CurrentModule('admin'))
        ->AddCurrent('Add Poll');

    // Show any validation errors from a previous POST attempt
    PB::Errors()->DisplayUIErrors($ui);

    $f = new POSTForm(PBURL::CurrentModule('create-poll'));
    $f->AddText('question', 'Poll Question', true)->SetFocus();
    $f->AddText('answer1',  'Answer 1',      true);
    $f->AddText('answer2',  'Answer 2',      true);
    $f->AddText('answer3',  'Answer 3',      false);
    $f->AddText('answer4',  'Answer 4',      false);
    $f->AddCheckbox('active', 'Active', true);
    $f->AddSubmit('Create Poll');
    $f->LoadValuesArray(PB::Requests()->POSTAsArray());

    $ui->Add($f);
    $ui->Output(true);
});

// ── create-poll ─────────────────────────────────────────────────
pb_on_action('create-poll', function () {
    PBAccess::AdminRequired();

    $question = PB::POST('question');
    $answer1  = PB::POST('answer1');
    $answer2  = PB::POST('answer2');

    if ($question->isEmpty()) {
        PB::Errors()->AddError('Poll question is required');
    }
    if ($answer1->isEmpty() || $answer2->isEmpty()) {
        PB::Errors()->AddError('At least two answers are required');
    }

    if (PB::Errors()->HasErrors()) {
        PB::Errors()->SwitchActionOnError('add-poll');
        return;
    }

    $pollId = Poll::Create(
        $question->AsString(),
        TCompany::CurrentCompany()->ID(),
        PB::POST('active')->AsBool()
    );

    foreach (['answer1','answer2','answer3','answer4'] as $key) {
        $val = PB::POST($key)->AsString();
        if ($val !== '') {
            PollAnswer::Create($pollId, $val);
        }
    }

    pb_notify('Poll created successfully!');
    Redirect::Now(PBURL::CurrentModule('admin'));
});

// ── edit-poll ───────────────────────────────────────────────────
pb_on_action('edit-poll', function () {
    PBAccess::AdminRequired();

    $poll = Poll::initSingleton(PB::GET('id')->AsAbsInt());
    PB::Show404IfEmpty($poll);

    pb_title('Edit Poll');
    pb_template('polls-admin');

    $ui = new UI();
    PB::BreadCrumbs()
        ->Add('Polls', PBURL::CurrentModule('admin'))
        ->AddCurrent('Edit Poll');

    PB::Errors()->DisplayUIErrors($ui);

    $f = new POSTForm(PBURL::CurrentModule('update-poll', ['id' => $poll->ID()]));
    $f->AddText('question', 'Poll Question', true);
    $f->AddCheckbox('active', 'Active', false);
    $f->AddSubmit('Save Changes');

    // Populate from DB (or from POST data on error)
    if (!PB::Errors()->HasErrors()) {
        $f->LoadValuesArray(['question' => $poll->Title(), 'active' => $poll->IsActive()]);
    } else {
        $f->LoadValuesArray(PB::Requests()->POSTAsArray());
    }

    $ui->Add($f);
    $ui->Output(true);
});

// ── update-poll ─────────────────────────────────────────────────
pb_on_action('update-poll', function () {
    PBAccess::AdminRequired();

    $poll = Poll::initSingleton(PB::GET('id')->AsAbsInt());
    PB::Show404IfEmpty($poll);

    $question = PB::POST('question');
    if ($question->isEmpty()) {
        PB::Errors()->AddError('Question is required');
        PB::Errors()->SwitchActionOnError('edit-poll');
        return;
    }

    $poll->UpdateValues([
        'title'  => $question->AsString(),
        'active' => PB::POST('active')->AsBool() ? 1 : 0,
    ]);

    pb_notify('Poll updated!');
    Redirect::Now(PBURL::CurrentModule('admin'));
});

// ── delete-poll ─────────────────────────────────────────────────
pb_on_action('delete-poll', function () {
    PBAccess::AdminRequired();

    $poll = Poll::initSingleton(PB::GET('id')->AsAbsInt());
    if (!empty($poll)) {
        $poll->Remove();   // triggers OnRemoveBeforeStart() which deletes answers
        pb_notify('Poll deleted.');
    }

    Redirect::Now(PBURL::CurrentModule('admin'));
});

File 2: modules/preload/polls-models.php

php
<?php
// modules/preload/polls-models.php
// Auto-loaded by the framework after pb_register_autoload('polls-models')

/**
 * Single poll (one question + multiple answers)
 *
 * @method static self initSingleton($id)
 * @method static self UsingCache($id)
 * @method static self initNotExistent()
 */
class Poll extends PBDBObject
{
    public function TableName(): string        { return 'polls'; }
    public function FieldNameForID(): string    { return 'id'; }
    public function FieldNameForTitle(): string { return 'title'; }

    public function TotalVotes(): int { return $this->FieldIntValue('total_votes'); }
    public function IsActive(): bool  { return $this->FieldBoolValue('active'); }

    /**
     * Create a new poll and return its ID.
     */
    public static function Create(string $title, int $companyId, bool $active = true): int {
        $poll = self::CreateWithParams([
            'title'      => $title,
            'id_company' => $companyId,
            'active'     => $active ? 1 : 0,
            'timeadded'  => time(),
        ]);
        return $poll->ID();
    }

    /** Delete all answers before removing the poll row */
    protected function OnRemoveBeforeStart(): void {
        execsql("DELETE FROM poll_answers WHERE id_poll = " . $this->ID());
    }
}

/**
 * Single answer option for a poll
 *
 * @method static self initSingleton($id)
 * @method static self UsingCache($id)
 */
class PollAnswer extends PBDBObject
{
    public function TableName(): string        { return 'poll_answers'; }
    public function FieldNameForID(): string    { return 'id'; }
    public function FieldNameForTitle(): string { return 'title'; }

    public function Votes(): int   { return $this->FieldIntValue('votes'); }
    public function PollID(): int  { return $this->FieldIntValue('id_poll'); }

    public static function Create(int $pollId, string $title): int {
        $ans = self::CreateWithParams(['id_poll' => $pollId, 'title' => $title, 'votes' => 0]);
        return $ans->ID();
    }

    public function RecordVote(): void {
        $this->Increment('votes');
        execsql("UPDATE polls SET total_votes = total_votes + 1 WHERE id = " . $this->PollID());
    }
}

/** Fetch a list of Poll objects */
class PollList extends PBDBList
{
    public function ObjectClassName(): string { return Poll::class; }

    public function SetFilterCompany(int $id): self {
        $this->SetFilterFieldIntValue('id_company', $id);
        return $this;
    }

    public function SetFilterActive(bool $active): self {
        $this->SetFilterFieldIntValue('active', $active ? 1 : 0);
        return $this;
    }
}

/** Fetch answers for a specific poll */
class PollAnswersList extends PBDBList
{
    public function ObjectClassName(): string { return PollAnswer::class; }

    public function SetFilterByPoll(int $pollId): self {
        $this->SetFilterFieldIntValue('id_poll', $pollId);
        return $this;
    }
}

File 3: themes/default/polls.tpl (Public Voting Page)

smarty
{* themes/default/polls.tpl — Public-facing voting page *}

{if $m.action eq 'list'}
    {include file="block_begin.tpl"}
    <h2>Community Polls</h2>

    {foreach from=$m.polls item=poll}
        <div class="poll-card" style="margin-bottom:1.5rem;">
            <h3><a href="index.php?m=polls&d=view&id={$poll.id}">{$poll.title}</a></h3>
            <small>{$poll.total_votes} votes</small>
        </div>
    {foreachelse}
        <p>No active polls at this time.</p>
    {/foreach}

    {include file="paginator.tpl"}
    {include file="block_end.tpl"}

{elseif $m.action eq 'view'}
    {include file="block_begin.tpl"}
    <h2>{$m.poll.title}</h2>
    <p>{$m.poll.total_votes} votes cast so far</p>

    <form method="post" action="index.php?m=polls&d=vote">
        <input type="hidden" name="poll_id" value="{$m.poll.id}" />

        {foreach from=$m.answers item=answer}
            <label style="display:block;margin:0.5rem 0;">
                <input type="radio" name="answer_id" value="{$answer.id}" />
                {$answer.title} ({$answer.votes} votes)
            </label>
        {/foreach}

        <button type="submit" class="btn btn-primary" style="margin-top:1rem;">Cast Vote</button>
    </form>

    {include file="block_end.tpl"}

{elseif $m.action eq 'results'}
    {include file="block_begin.tpl"}
    <h2>Results: {$m.poll.title}</h2>

    {foreach from=$m.answers item=answer}
        <div style="margin:0.75rem 0;">
            <strong>{$answer.title}</strong>: {$answer.votes} votes
            {if $m.poll.total_votes gt 0}
                ({math equation="round(votes/total*100)" votes=$answer.votes total=$m.poll.total_votes}%)
            {/if}
        </div>
    {/foreach}

    <p><a href="index.php?m=polls&d=list">&larr; All Polls</a></p>
    {include file="block_end.tpl"}
{/if}