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.