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/Example.php              # your /api surface — Billing_Service_Example
├── views/scripts/index/index.phtml   # .phtml view scripts
├── configs/
│   ├── module.ini                    # module settings
│   ├── acl.ini                       # who can reach what (deny-by-default)
│   ├── routes.ini                    # pretty URLs (optional — the canonical path works free)
│   └── dependency.ini                # modules this one requires
├── models/                           # Billing_Model_* (extend Tiger_Model_Table)
├── migrations/                       # additive-only schema changes
├── layouts/                          # optional — a module can ship its own
└── assets/                           # published to /_modules/billing on activate

models/, migrations/, layouts/ and assets/ are created empty, ready for you to fill. Add forms/ (Billing_Form_*) and languages/en/billing.php yourself when you need them — they're conventions Tiger discovers, not files the generator writes.

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. The generator gave you services/Example.php; rename it to services/Invoice.php (renaming the class to match) 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/.

Ready to build one for real — with a table, settings, and a clean uninstall? Walk through it end to end in How to write your first module. From there, the Modules reference goes deep on structure and lifecycle, Forms shows the validated-input pattern, and Authorization explains how acl.ini gates every call.