Skip to content

Your first module

You've installed Tiger. It runs. Now let's build something — and see the whole philosophy in about five minutes. In Tiger, a feature is a folder. You scaffold it, it plugs in by convention, and you never touch a core file to do it.

Scaffold it

vendor/bin/tiger make:module billing

That one command drops a live, wired-up feature into application/modules/billing/:

application/modules/billing/
├── Bootstrap.php                 # the module's entry point (auto-run on boot)
├── controllers/IndexController.php
├── services/Invoice.php          # your /api surface — Billing_Service_Invoice
├── models/                       # Billing_Model_* (extend Tiger_Model_Table)
├── forms/                        # Billing_Form_* (extend Tiger_Form)
├── views/scripts/index/          # .phtml view scripts
├── configs/
│   ├── module.ini                # module settings
│   ├── acl.ini                   # who can reach what (deny-by-default)
│   └── navigation.ini            # admin nav entries
├── migrations/                   # additive-only schema changes
├── languages/en/billing.php      # translation keys
└── assets/                       # published to /_modules/billing on activate

Nothing here edits Tiger. The module is purely additive — Tiger discovers it, reads its config, and wires it in.

Turn it on

vendor/bin/tiger module:activate billing

Activation is zero-infrastructure — no Apache/nginx edit, no DNS, no filesystem shuffling outside the module's own dir. Its assets/ get symlinked to public/_modules/billing, and it just works. (This is why a module can never touch web-server config — it would break 1-click install. See Routing.)

Now visit its canonical route:

/billing/index/index          → Billing_IndexController::indexAction

That path exists for free, with zero route registration. Want a pretty /billing instead? You declare an override — you don't hand-edit routes (Routing covers it).

Add behavior: write a method, ship an endpoint

The heart of a module is its service — the /api-reachable unit where logic lives. Open services/Invoice.php and add a method:

class Billing_Service_Invoice extends Tiger_Service_Service
{
    public function totalDue(array $params): void
    {
        if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; }

        $orgId = (string) $params['org_id'];
        $total = (new Billing_Model_Invoice())->sumOutstanding($orgId);

        $this->_success(['total' => $total]);
    }
}

That's it — it's live. No endpoint to register, no route to add, no versioned URL to maintain. The client calls it by naming it in the message:

fetch('/api', { method: 'POST', body: new URLSearchParams({
    module: 'billing', service: 'invoice', method: 'totalDue', org_id: orgId,
})}).then(r => r.json());

Add a method → it's callable. That's the whole loop. (The Webservices page unpacks why this beats REST-by-URL.)

The takeaway

You just built a feature without touching a single Tiger file. That's the model, all the way down:

  • A feature is a module — a folder that plugs in by convention.
  • Logic lives in services, reached over one /api endpoint.
  • Nothing you built gets clobbered by composer update, because it all lives outside vendor/.

From here, the Modules reference goes deep on structure and lifecycle, Forms shows the validated-input pattern, and Authorization explains how acl.ini gates every call.