How to write your first module
This is the hands-on walkthrough: from an empty scaffold to a working, ACL-gated feature that stores its own data and settings — start to finish. It assumes Tiger is installed and running. For the five-minute philosophy tour see Your first module; for the structure reference see Modules.
We'll build a tiny billing module with one table, one /api method, and its own settings.
1. Scaffold it — never hand-build the folder
vendor/bin/tiger make:module billing
That drops a live, wired feature into application/modules/billing/ — a controller, an /api
service, acl.ini, views, config, and empty models/ + migrations/ folders. It's discovered on
the next request; you never register it anywhere. (A forms/ dir and languages/en/billing.php
are conventions Tiger discovers — add them yourself when you need them.)
Do generate the module. Don't copy another module's folder by hand — you'll miss the ACL wiring and the naming conventions the loader depends on.
2. Give it a table — a migration with a real down
Add migrations/20260101120000_create_invoice.php. Migrations are additive-only, one DDL change
per file, and every domain table carries the standard columns
(status, deleted, created_by, updated_by, created_at, updated_at).
<?php
return [
'up' => [
"CREATE TABLE billing_invoice (
invoice_id CHAR(36) NOT NULL PRIMARY KEY,
org_id CHAR(36) NOT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'open',
deleted TINYINT(1) NOT NULL DEFAULT 0,
created_by CHAR(36) NULL,
updated_by CHAR(36) NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL
)",
],
'down' => [
"DROP TABLE billing_invoice",
],
];
Do write a real
down. It's not busywork — it's what lets Tiger cleanly drop your tables when the module is deleted. A migration with an emptydownleaves orphaned tables behind forever. Don't rename or edit an already-applied migration — add a new one.
Prefix every table with your module slug (billing_*) so it's unmistakably yours.
3. Model it — extend Tiger_Model_Table
// models/Invoice.php
class Billing_Model_Invoice extends Tiger_Model_Table
{
protected $_name = 'billing_invoice';
protected $_primary = 'invoice_id';
/** Outstanding total for an org (excludes soft-deleted rows via activeSelect()). */
public function sumOutstanding(string $orgId): float
{
$select = $this->activeSelect()
->from($this->_name, [new Zend_Db_Expr('COALESCE(SUM(amount),0) AS total')])
->where('org_id = ?', $orgId)
->where('status = ?', 'open');
return (float) $this->getAdapter()->fetchOne($select);
}
}
The base mints the UUID PK on insert, stamps the actor + timestamps, and soft-deletes.
Do build queries with the query builder (
activeSelect()+ boundwhere('col = ?', $v)). Don't concatenate SQL strings — it's the injection footgun the builder exists to remove.
4. Add behavior — a service method (validate → transaction)
The service is the /api-reachable unit; logic lives here, not in controllers.
// services/Invoice.php
class Billing_Service_Invoice extends Tiger_Service_Service
{
public function create(array $params): void
{
if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; }
$form = new Billing_Form_Invoice();
if (!$form->isValid($params)) { $this->_formErrors($form); return; }
try {
$id = $this->_transaction(function ($db) use ($params) {
return (new Billing_Model_Invoice())->insert([
'org_id' => $params['org_id'],
'amount' => $params['amount'],
]);
});
$this->_success(['invoice_id' => $id], 'billing.invoice.created');
} catch (Throwable $e) {
$this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general');
}
}
}
The client calls it by naming it — no endpoint to register:
fetch('/api', { method: 'POST', body: new URLSearchParams({
module: 'billing', service: 'invoice', method: 'create', org_id: orgId, amount: '49.00',
})}).then(r => r.json());
Do validate a form first, then wrap writes in
_transaction(). Don't put business logic in a controller, run a mutation without a form + transaction, or return a bare error string — always_error()/_formErrors()with a translation key.
5. Gate it — declare the ACL (deny-by-default)
/api is deny-by-default: a service with no allow rule is refused. Declaring the rule is part of
writing the call. In configs/acl.ini:
; resource = the service class; a rule with NO privilege allows ALL its methods to the role
acl.resources.billing_invoice_svc.resource = "Billing_Service_Invoice"
acl.rules.billing_invoice_svc.role = "admin"
acl.rules.billing_invoice_svc.resource = "Billing_Service_Invoice"
acl.rules.billing_invoice_svc.permission = "allow"
Do add the ACL rule when you add the service — the in-method
_isAdmin()is defense-in-depth, not the gate. Don't compare role strings in code; access is data, resolved live from the ACL.
Forms extend Tiger_Form (array-config elements(), validated on submit and on blur) — see
Forms. Strings are semantic, owner-prefixed keys (billing.invoice.created) in
languages/en/billing.php — see Internationalization.
6. Persist settings & state — config vs option
Sooner or later your module needs to remember something: an admin setting, or some per-user state. Tiger gives you two disciplined key/value stores — reach for the right one, and never stand up your own settings table.
| Store | Tiger_Model_Config |
Tiger_Model_Option |
|---|---|---|
| Loaded | eager — every row folds into the config cascade on every request | lazy — read only when its owner asks |
| Use for | module settings: a handful of keys, read often, admin-set | per-user / per-entity state: dashboard layout, dismissed notices, wizard progress |
| Rule of thumb | influences the request-wide config? → config |
private state only your feature reads? → option |
Both share the same (scope, scope_id, key, value) shape. Scopes: SCOPE_GLOBAL (id ''),
SCOPE_ORG (an org id), SCOPE_USER (a user id).
A module setting (config):
$cfg = new Tiger_Model_Config();
// write:
$cfg->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'billing.tax_rate', '0.08');
// read:
$rate = (float) $cfg->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'billing.tax_rate');
Per-user state (option) — with a JSON value:
$opt = new Tiger_Model_Option();
// write a structured value:
$opt->setJson(Tiger_Model_Option::SCOPE_USER, $userId, 'billing.dashboard_layout', ['cols' => 3]);
// read it back (with a default):
$layout = $opt->getJson(Tiger_Model_Option::SCOPE_USER, $userId, 'billing.dashboard_layout', ['cols' => 2]);
⚠️ The one rule that matters most: prefix every key with your module slug —
billing.tax_rate, nottax_rate, and never under thetiger.*namespace (that's the platform's). This is the owner-prefix convention, the same one i18n and ACL use. It's what makes your keys attributable to your module — so when someone deletes your module, Tiger sweeps yourconfig/optionrows automatically and leaves no cruft behind. An un-prefixed key is an orphan waiting to happen.Do use
configfor settings andoptionfor on-demand state. Don't create abilling_settingstable (that's whatconfigis for), and don't pour a pile of per-user rows intoconfig(it loads on every request — that's whatoptionis for).
7. Turn it on
vendor/bin/tiger module:activate billing # runs your migrations, symlinks assets/ → public/_modules/billing
Activation is zero-infrastructure — no Apache/nginx edit, no DNS. Your canonical routes exist for
free (/billing/invoice/create → the controller; the /api message → the service). Want a pretty
/billing? Declare a route override — don't hand-add a route.
Dos & Don'ts — the cheat sheet
Do
- Scaffold with
make:module; keep controllers thin, services fat. - Give every table the standard columns and a real migration
down. - Declare an
acl.inirule for every service (deny-by-default). - Build queries with the query builder; wrap writes in
_transaction(). - Store settings in
config, per-user state inoption, and prefix every key with your slug. - Keep user-facing strings in owner-prefixed language keys.
Don't
- Edit anything under
vendor/— it's replaced bycomposer update. Extend, don't edit. - Touch web-server config, DNS, or anything outside your module's own dir.
- Create your own settings/options table, or write under the
tiger.*namespace. - Leave a migration with an empty
down, or an un-prefixed config/option key — both leave orphans when the module is uninstalled. - Hardcode roles, strings, or config; put logic in a controller; or page-POST a form (the UI is a
client that calls
/api).
Clean by construction
Follow the conventions above and your module is cleanly removable: deleting it drops its tables
(via your migration down), sweeps its config/option rows (via the slug prefix), unpublishes its
assets, and deletes its files — nothing stranded. That's the payoff of the ownership model: a module
that plugs in additively also unplugs cleanly.
Next: Modules (structure + lifecycle) · Forms · Authorization · Webservices · Routing.