Skip to content

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 empty down leaves 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() + bound where('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 slugbilling.tax_rate, not tax_rate, and never under the tiger.* 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 your config/option rows automatically and leaves no cruft behind. An un-prefixed key is an orphan waiting to happen.

Do use config for settings and option for on-demand state. Don't create a billing_settings table (that's what config is for), and don't pour a pile of per-user rows into config (it loads on every request — that's what option is 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.ini rule for every service (deny-by-default).
  • Build queries with the query builder; wrap writes in _transaction().
  • Store settings in config, per-user state in option, 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 by composer 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.