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
/apiendpoint. - Nothing you built gets clobbered by
composer update, because it all lives outsidevendor/.
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.