Instructions for an AI assistant (or a new human contributor) working in this codebase.
Follow the patterns already here; don't invent new ones. For the why read
ARCHITECTURE.md; for the feature surface read FEATURES.md;
for the /api model read WEBSERVICES.md; for URLs + module route overrides
read ROUTING.md; for building an admin screen read ADMIN.md; for installing on shared cPanel read CPANEL.md; for buying/selling
paid modules (the open licensing protocol + the buyer-side client) read MARKETPLACE.md; for comments/ratings/reviews read COMMENTS.md,
and for the seller side (list free / sell paid → Add Module, + the build status) read SELLING.md.
Weighing whether to build on Tiger at all — or handed this repo cold — start with WHY-TIGER.md.
This file documents TigerCore + app conventions. Tiger is designed to be read and written largely by AI — so the docs live in the code. Match the surrounding style.
Tiger is large and most substrate is already built. Before you build (or claim something is missing),
consult CAPABILITIES.md first — a generated, capability-grouped index of every
Tiger_* class + module with its one-line docblock summary, @api status, and path. grep it for the
thing you need (grep -i install CAPABILITIES.md, grep -i 2fa CAPABILITIES.md) before assuming it
isn't there. It's regenerated from docblocks (bin/build-capabilities.php) and CI-checked, so it can't
drift. Assume a capability exists until you've grepped the index and the code and confirmed it doesn't.
The docs answer what/why (FEATURES.md / ARCHITECTURE.md); CAPABILITIES.md
answers "does it exist, and where?"; BACKLOG.md is the short list of what is genuinely
not yet built.
vendor/ is Tiger-owned and replaced by composer update. Never edit framework files to
change app behavior — you'll lose it on the next update. Extend instead:
- Add
_init*methods in the app'sBootstrap(which extendsTiger_Application_Bootstrap). - Subclass (
App_Service_Base extends Tiger_Service_Service), override config.ini, or add a module — don't patchTiger_*. @api= stable to build on;@internal= may change.
Zend_* (engine, in TigerZF) · Tiger_* (platform) · App_* (app-shared, route-less) ·
modules (features with routes/UI). A feature with controllers/routes/ACL/views is a
module; shared plumbing with no routes is the app library.
- Short arrays
[], neverarray()— in TigerCore and app code. (TigerZF keepsarray()to match upstream ZF1; don't touch it.) - Docblock every class and non-trivial method, explaining intent, not mechanics. Comment density here is higher than typical — keep it.
- Constants for magic values; guard clauses over deep nesting;
Throwablein catches. - No URL query params for navigation. Use ZF1 path-style params —
/auth/login/out/1, not?out=1(the:controller/:action/*route folds trailing pairs intogetParam()); tokenized links are path-style too. A return/intended-destination path lives in the session, never the URL (seeTiger_Service_Authentication::setReturnTo/takeReturnTo) — avoids%2Fpath 404s and history leakage.
The public API reference is generated from docblocks, so consistency here is what makes that
possible. Every @api class and every public method follows this shape. Protected/private
methods aren't part of the contract — document them where helpful, but they're not required and
never appear in the reference.
Class docblock — the SPDX line lives in the top-of-file // comment, so the docblock is purely
the API description:
/**
* Tiger_Model_Table — <one concise sentence: what this class IS>.
*
* <Longer description: the design, the why, how to use it. Paragraphs encouraged — the reference
* renders these as the class page body.>
*
* @api ← REQUIRED on every class: @api (stable, build on it) or @internal
* @see Tiger_Model_Org ← optional; becomes a cross-link
* @since 0.4.0 ← optional
*/Public method docblock — a summary plus the tags the generator reads. Types stay in the signature (PHP 8); the docblock adds the descriptions:
/**
* <One line, imperative — "Resolve a doc across both sources.">
*
* <Optional longer description.>
*
* @param string $slug the URL slug (may be nested)
* @param int $limit max results
* @return array the ranked hits
* @throws Zend_Db_Exception on a DB failure
*/Rules:
- Fixed tag order: description →
@param(one per parameter, in order) →@return→@throws→@see/@deprecated. Keeps the parser trivial and the output uniform. - Every public method gets a docblock with a summary, a
@paramfor each parameter, and a@return(use@return voidfor no return value). Constructors included. @param <type> $name <desc>with a real type expression (string,int,?Foo,Foo|null,array<int,string>) — the generator prefers the reflected type and uses this line for the description.- Augment, don't rewrite. Existing prose is good; this is about adding the tags + filling gaps, never redoing what's there. Never change code, signatures, or behavior.
Tiger apps are client/server. The server renders the initial HTML — the page shell and
public CMS page bodies — and from there the browser is a client that exchanges data with
/api over AJAX. It does not submit <form> page-POSTs to a controller and re-render the
whole page. Every dynamic interaction is a Tiger Webservice call:
- Auth & session — login, logout, lock-screen unlock, signup, password reset are AJAX. (Auth
is a reserved kernel service, so these post to a thin controller that returns JSON — e.g.
/auth/login— rather than literal/api; it's the same AJAX-JSON contract. See WEBSERVICES.md §8.) - Data in — every insert / update / delete is an
/apiservice call (validate → transaction); the JSON response drives the client (redirect, inline field errors, toast). - Data out — list / table data (DataTables, Select2, autocompletes) is fetched from
/api, not server-rendered into rows. DataTables loads its rows over AJAX and the service returns the DataTables shape ({data:[…]}for an ajax source, or{draw, recordsTotal, recordsFiltered, data}for server-side processing). See WEBSERVICES.md §5.
SSR is for the shell, not the data. A controller action renders the initial screen and then
gets out of the way; the logic and the data live in services. The payoff is the front-end-agnostic
contract (FEATURES.md): the same /api feeds the PUMA SSR theme, a future SPA theme, or a mobile
client, because the UI is always just a client.
The difference between a cheap-feeling UI and a polished one is almost entirely in the small
moments — how a button behaves while it's working, how a message arrives and leaves. Tiger ships
two tiny vanilla helpers (zero deps, in the PUMA theme) that make the polished path the easy
path. Use them; never hand-roll a spinner, a busy flag, or innerHTML = '<div class="alert">'
again.
TigerButton.run(btn, task)(tiger.button.js) — wrap the promise behind any AJAX action button. It disables the button, swaps its icon to a spinner (injecting one if the button has none), holds a minimum visible time (~400ms) so the state actually registers even on instant calls, then restores + re-enables — failure-safe (finally) andaria-busy. The task must return the fetch chain; chain your response handling ontorun()'s return:TigerButton.run(this, function () { return fetch('/api', { method: 'POST', body: fd }).then(function (r) { return r.json(); }); }).then(function (res) { /* … */ }).catch(function () { /* … */ });
TigerDOM.notify(container, msg, {type})/.toast(msg, {type})(tiger.dom.js) — the message envelope. Builds the themed alert (icon + message + close) and reveals it (container expands, then the content fades in — never slammed on), with smart defaults: success auto-dismisses (~5s), errors stick, pause-on-hover, ✕/click to dismiss.notifytargets an inline feedback element;toastfloats it top-right.TigerDOM.notify(fb, 'Settings saved.', { type: 'success' }); TigerDOM.notify(fb, m.message, { type: m.class }); // res.messages[].class maps straight through
TigerDOM.expand/collapse/toggle— the underlying reveal primitives (Web Animations API, interruptible,prefers-reduced-motion-aware). Reach for these for any show/hide (submenus, accordions, panels) instead of a jQuery slide or a rawdisplayflip.TigerModal.confirm/prompt/alert(tiger.modal.js) — the in-app replacement for the browser's nativeconfirm()/prompt()/alert(), which are never used (unstyled, un-themeable, freeze the tab). Promise-based, themed, and it builds its one reusable Bootstrap modal on demand so a view needs no modal markup:confirm()→Promise<boolean>,prompt()→Promise<string|null>(nullon cancel, mirroringwindow.promptsoif (v === null) return;swaps straight in). Enter submits; the field focuses on open. Depends only on Bootstrap (loaded ahead of it in every layout).TigerModal.confirm({ title: 'Remove card', body: 'Remove this card?', confirmLabel: 'Remove', variant: 'danger' }) .then(function (ok) { if (!ok) { return; } /* … */ });
The rule of thumb: a control should always say what it's doing. A save button that just sits there, or a message that blinks into existence and never leaves, is the tell of a cheap UI. These helpers are the house style — every admin + auth screen already uses them; match that.
Services extend Tiger_Service_Service; the message's method names the action, which
receives the whole $params. Always validate a form first, then wrap mutations in a
transaction:
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; } // isValidPartial() for PATCH
try {
$id = $this->_transaction(function ($db) use ($params) {
// ... inserts/updates; throw to abort + roll back ...
return $newId;
});
$this->_success(['id' => $id], 'billing.invoice.created');
} catch (Throwable $e) {
$this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general');
}
}Never emit business errors as bare strings or raw exceptions — use _error/_formErrors
with a translation key. Never put business logic in a controller; controllers are thin.
Declaring the ACL is part of writing the call, not an afterthought. /api is deny-by-default:
the dispatcher checks isAllowed($role, <ServiceClass>, <method>) — the resource is the service
class, the privilege is the method name. A service with no allow rule is refused outright, so
every service you add needs an entry in the module's 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"Adding a method to an already-allowed service inherits that blanket allow — correct when the whole
service shares one role (an admin-only settings service, a guest-allowed search service). When
methods need different access (a public read on an otherwise-admin service), name the method as
the privilege on a scoped rule instead of a blanket one. The in-method _isAdmin() guard is
defense-in-depth — it does not replace the acl.ini rule.
Extend Tiger_Form (not Zend_Form directly). Declare elements as an array schema via
elements() (array config, not .ini); the base handles POST method, ViewHelper-only
decorators (the view owns markup), CSRF, and the _t() translate helper.
class Billing_Form_Invoice extends Tiger_Form
{
protected function elements(): array
{
return [
['text', 'amount', [
'required' => true,
'filters' => ['StringTrim'],
'validators' => [['Float']],
'attribs' => ['class' => 'form-control', 'placeholder' => $this->_t('billing.invoice.amount')],
]],
];
}
}Convenience validation is free — declare validators once. The validators you put on an
element run at submit (isValid()) and on blur, before submit: add data-tiger-validate data-module="<mod>" data-form="<FormName>" to the <form> and tiger.validate.js posts each
field to Tiger_Service_Validate, which runs that element's real validators
(Tiger_Form::convenienceValidate) and shows the first error inline. So don't hand-roll
per-field AJAX checks — just declare the validator. modules/signup/forms/Signup.php +
its view are the reference form: every field type, DB-uniqueness (Zend_Validate_Db_NoRecordExists),
password policy (Tiger_Validate_Password), address autocomplete, and the localized country
picker (Tiger_I18n_Country). Copy that when building a new form.
Platform helpers worth knowing before you build your own: Tiger_Location (address /
geocode / reverse / IP behind adapters, normalized Place payload) and Tiger_I18n_Country
(biased, localized country list). Both are config-driven and reachable via the public
Tiger_Service_Location when a form needs them.
- Extend
Tiger_Model_Table. It mints the UUID PK on insert (v7 default; set$_uuidVersion = 4for opaque tokens), stamps timestamps +created_by/updated_by, and soft-deletes. Domain finders build onactiveSelect()(excludes deleted). - Build every query with the query builder — never a raw SQL string.
activeSelect()/$db->select()->from(...)+ boundwhere('col = ?', $v), andnew Zend_Db_Expr('COUNT(*)')for aggregates. Parameterized, portable, injection-safe. No$db->query("SELECT …")/ string-concatenated SQL in models or services. - Every domain table gets the standard columns:
status,deleted,created_by,updated_by,created_at,updated_at. UUIDs areCHAR(36); unique text isVARCHAR(191). - JSON-shaped data →
LONGTEXT, never theJSONtype. The DB stores text; validating JSON is the app's job (you alreadyjson_encode/json_decodeit). MariaDB'sJSONisLONGTEXT+ an implicitCHECK(json_valid())that rejects JSON nested ≥ 32 levels (which PHP encodes fine) — it broke the CMS builder's deep project blob (migration0041converted the existing columns). - Migrations are additive-only PHP files (
NNNN_name.phpreturning['up'=>[], 'down'=>[]]) inmigrations/. One logical DDL change per migration (MySQL auto-commits DDL).
Deny-by-default. Access is data in acl.ini / acl_* tables, never a hardcoded role compare.
A resource with no allow rule is denied. Role comes from org_user (role-on-membership) and
is resolved live each request. Module configs/acl.ini grants access to that module's
controllers/services. Two resource shapes: a controller dispatch gates on
isAllowed($role, <Controller>, <action>) (privilege = action); an /api service gates on
isAllowed($role, <ServiceClass>, <method>) (privilege = method). Declaring the rule is part of
writing the call — see "Services (/api)".
- Never hardcode user-facing strings. Use a semantic, owner-prefixed key:
core.*(framework),app.*(app),<module>.*(module), then.area.type.name— e.g.billing.invoice.created,core.api.error.not_allowed. - Put strings in PHP array files:
<module>/languages/<lang>/<module>.phpreturning['key' => 'text']. Locales are language-only (en,es). - Response messages translate automatically (
Tiger_Model_MessageObject). In a form use$this->_t('key')(Tiger_Form::_t). In a view there is NO_thelper — pull the registered translator:Zend_Registry::get('Zend_Translate')->translate('key')(or a small local$t = fn($k) => Zend_Registry::get('Zend_Translate')->translate($k);closure).$this->_t()/$this->view->_t()in a view/controller throwsPlugin '_t' not found.
Config and translations both follow: files/ini = base, a DB table = the runtime override
tier (last wins), no deploy. When you build a new "options/settings" surface, mirror this —
a *_key/*_value table with scope (global|org) + scope_id, loaded on top of the base in
a bootstrap _init*. See Tiger_Model_Config / Tiger_Model_Translation.
- New feature →
vendor/bin/tiger make:module <name>(gives a live controller +/apiservice + ACL + views + config to build from). - Keep secrets out of the repo — DB creds and keys go in
local.ini(gitignored) or a secrets manager, never inapplication.inior committed files.
- Don't edit anything under
vendor/. - Don't use
array()in TigerCore/app code. - Don't hardcode user-facing strings, roles, or config — use keys, ACL data, and config.
- Don't put logic in controllers, or run mutations without a form-validate + transaction.
- Don't build REST-by-URL endpoints — use the
/apimessage pattern. - Don't put routes in a Bootstrap — that's cruft. Routes are config, not code. Prefer automagic
default MVC: name the controller/action so the URL falls out (
/foo→FooController::indexAction,/index/vibe→IndexController::vibeAction) and register nothing. When a pretty alias is genuinely needed, it goes in a.ini, never a Bootstrap_init/addRoute:- Core / default-namespace aliases →
configs/routes.ini(ZF1-nativeresources.router.routes.*, folded into the config cascade; the Router resource applies them). - Module pretty aliases → a
Tiger_Routing_Overridesoverride (one authority owns ordering; see ROUTING.md). Reach a feature at its canonical<module>/<controller>/<action>path by default; only add a route for a nicer URL.
- Core / default-namespace aliases →
- Don't page-POST forms to controllers or server-render list/table data — the UI is a client
that calls
/api; controllers render the initial shell only (see the client/server section). - Don't hand-roll a button spinner/busy flag or
innerHTML = '<div class="alert">'— useTigerButton.runandTigerDOM.notify/toast(see "UI/UX: polished by default").