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
| Type | Description | Access |
|---|---|---|
| Visitor-Facing (public) | Pages accessible without login (landing pages, forms, etc.) | No auth required |
| Registered / Privileged | Admin or secured user-facing pages within the CRM dashboard | Requires login or admin role |
File Roles
| File | Purpose |
|---|---|
| modules/mymodule.php | Controller — registers action handlers via pb_on_action() |
| themes/default/mymodule.tpl | Smarty template — renders the HTML output |
| themes/default/mymodule.css | Module-specific CSS (loaded via pb_add_cssfile()) |
| themes/default/mymodule.js | Module-specific JavaScript (loaded via pb_add_jsfile()) |
| modules/preload/mymodule-models.php | Model classes, auto-loaded by the framework |
URL Pattern
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
httpdocs/
├── modules/
│ └── polls.php ← Controller
└── themes/
└── default/
└── polls.tpl ← Template
Controller: modules/polls.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
{* 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}
action, mode, or panel as keys in pb_set_tpl_var() — these are set by the framework and will be overwritten.
Access via URL
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
/* * Module Name: Polls Management */
Full Admin Module Skeleton
<?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
- Place
modules/polls-admin.phpin the modules directory - Place model preload file at
modules/preload/polls-models.php - Log in as super admin
- Navigate to Admin → Modules → Manage Modules
- Find "Polls Management" in the list → click Install
4. Core Functions Reference
| Function | Description |
|---|---|
| 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 |
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
| Method | Returns | Description |
|---|---|---|
| isEmpty() | bool | True if the param is absent or empty string |
| Exists() | bool | True if the param key is present in the request |
| AsString() | string | Raw string value (not escaped) |
| EscapedString() | string | MySQL-escaped string — use in raw SQL queries |
| AsInt() | int | Integer value (can be negative) |
| AsAbsInt() | int | Absolute (non-negative) integer — use for IDs |
| AsFloat() | float | Floating point value |
| AsBool() | bool | Boolean — truthy string values return true |
| AsArray() | array | Array value (for multi-select inputs) |
| isStringEqual($str) | bool | Case-sensitive string comparison |
Bulk Request Access
| Method | Description |
|---|---|
| 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
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
$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)
// 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
$b = new Buttons();
$b->Button('Add Poll', PBURL::CurrentModule('add-poll'));
$b->Button('Export', PBURL::CurrentModule('export'), 'secondary');
$ui->Add($b);
Grid (Data Table)
$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
| Method | Color | Description |
|---|---|---|
| UI::NotificationSuccess($msg) | Green | Action completed successfully |
| UI::NotificationError($msg) | Red | Action failed |
| UI::NotificationWarning($msg) | Amber | Action completed with caveats |
| UI::NotificationInfo($msg) | Blue | Informational message |
Error Collection
// 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
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
| Method | Returns | Description |
|---|---|---|
| TableName() | string | The database table name |
| FieldNameForID() | string | The primary key column name (usually 'id') |
| FieldNameForTitle() | string | The column used as a display title by Title() |
Field Accessors (Read)
| Method | Description |
|---|---|
| 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
| Method | Description |
|---|---|
| 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
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
// 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
class PollList extends PBDBList
{
public function ObjectClassName(): string { return Poll::class; }
}
Filter Methods
| Method | Description |
|---|---|
| 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
| Method | Description |
|---|---|
| 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
$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
// 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
// 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
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
| Function | Description |
|---|---|
| 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
// 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
| Method | Description |
|---|---|
| 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
// 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
| Method | Description |
|---|---|
| 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
// 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();
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
{* 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
| Include | Purpose |
|---|---|
| {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) |
{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:
$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
/* * 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
// 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)
{* 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">← All Polls</a></p>
{include file="block_end.tpl"}
{/if}