Models & schema
A Tiger model is a table-data gateway — one class per table, extending Tiger_Model_Table. The base carries the enterprise boilerplate every domain table needs (UUID keys, timestamps, actor stamps, soft-delete) so your subclass is just a table name plus the finders your feature actually needs.
Extend Tiger_Model_Table, never Zend_Db_Table_Abstract
A model declares its table ($_name) and primary key ($_primary) and builds domain finders on top of the base:
class Billing_Model_Invoice extends Tiger_Model_Table
{
protected $_name = 'invoice';
protected $_primary = 'invoice_id';
/** Open invoices for an org, newest first — built on activeSelect() so deleted rows stay hidden. */
public function openForOrg(string $orgId): array
{
$select = $this->activeSelect()
->where('org_id = ?', $orgId)
->where('status = ?', 'open')
->order('created_at DESC');
return $this->fetchAll($select)->toArray();
}
}
Metadata loads lazily, so the class is cheap to construct without a DB.
UUID primary keys — minted in PHP on insert
insert() mints the primary key itself (you never pass it) and returns the UUID string — not ZF1's lastInsertId(), which is meaningless for a client-generated string PK:
$id = (new Billing_Model_Invoice())->insert(['org_id' => $orgId, 'amount' => 4200]);
// $id === 'x' — a 36-char lowercase UUID
The default is UUID v7 (time-ordered — its leading 48 bits are a millisecond timestamp, so inserts append near the right edge of the index like an auto-increment, and rows sort chronologically by PK). For a table whose id must not leak its creation time — tokens, secrets, reset/invite ids — set the version to 4 (opaque, fully random):
class Auth_Model_Token extends Tiger_Model_Table
{
protected $_name = 'auth_token';
protected $_primary = 'auth_token_id';
protected $_uuidVersion = 4; // opaque — no embedded timestamp
}
Rule of thumb: entities → v7, secrets/tokens → v4. (See Tiger_Uuid — v7(), v4(), timeOf(), isValid().)
The standard columns (maintained for you)
Every domain table carries these, and the base maintains each only if the column exists:
| Column | Maintained on | Meaning |
|---|---|---|
status |
you set it | lifecycle state (active / suspended / …) |
deleted |
softDelete() / restore() |
soft-delete flag; reads exclude deleted by default |
created_by / updated_by |
insert / update | the acting user_id (actor stamp; NULL = system/genesis) |
created_at / updated_at |
insert / update | timestamps (DATETIME, written by the app) |
org_id |
insert | the tenant stamp (see multi-tenancy) |
Anything you pass explicitly wins over the automatic value.
Actor & org stamping
created_by/updated_by come from the current actor, set request-wide by the auth layer on login; a CLI/system insert leaves it unset, so those rows get created_by = NULL (system/genesis). The tenant org_id is stamped the same way from the active membership.
Tiger_Model_Table::setActor($userId); // auth calls this on login
Tiger_Model_Table::setOrg($orgId); // auth calls this per request
// ... any insert()/update() now stamps this actor + org automatically
$who = Tiger_Model_Table::actor(); // the current user_id, or null
created_by/updated_by are deliberately not foreign-keyed — a stamp must never block deleting the user it points at. Audit trails (who-changed-what history) are an app concern; core ships the stamp columns, not a history table.
Soft-delete and activeSelect()
softDelete() flips deleted to 1 instead of removing the row (falling back to a hard delete only when the table has no deleted column); restore() reverses it. Reads exclude deleted rows by default:
$invoice = $model->findById($id); // null if missing OR soft-deleted
$invoice = $model->findById($id, true); // include a soft-deleted row
$model->softDelete("invoice_id = '$id'"); // deleted = 1
$model->restore("invoice_id = '$id'"); // deleted = 0
Build every finder on activeSelect(), not select() — it comes pre-scoped to deleted = 0, so soft-deleted rows stay hidden without you remembering the where each time. Use delete() only for a deliberate hard delete.
The query-builder rule: never a raw SQL string
Build every query with the ZF1 query builder — activeSelect() / $db->select()->from(...) with bound parameters, and Zend_Db_Expr for aggregates. It's parameterized, portable, and injection-safe.
$select = $this->activeSelect()
->from($this->_name, ['status', new Zend_Db_Expr('COUNT(*) AS n')])
->where('org_id = ?', $orgId) // bound, never concatenated
->group('status');
No $db->query("SELECT …"), no string-concatenated SQL in a model or a service — ever.
Schema conventions
When you write the migration for a new table, follow the substrate's conventions:
- UUID PKs are
CHAR(36)(canonical lowercase text — portable and readable in logs), InnoDB, utf8mb4 / utf8mb4_unicode_ci. - Unique-indexed text is
VARCHAR(191)(191 × 4 bytes < the 767-byte index limit — portable to older MySQL). DATETIME, notTIMESTAMP(no 2038 problem, no implicit-timezone surprises).- JSON-shaped data →
LONGTEXT, never theJSONcolumn type. You alreadyjson_encode/json_decodeit, so validating JSON is the app's job — and MariaDB'sJSONtype isLONGTEXTplus an implicitCHECK(json_valid())that rejects JSON nested ≥ 32 levels, which PHP encodes fine. That mismatch really broke the CMS builder's deep project blob; store JSON asLONGTEXT.
Migrations
Schema changes are additive-only PHP files in a migrations/ dir, named NNNN_snake_name.php, returning ['up' => [...], 'down' => [...]] — arrays of statements run in order:
<?php
return [
'up' => [
"CREATE TABLE `invoice` (
`invoice_id` CHAR(36) NOT NULL,
`org_id` CHAR(36) NOT NULL DEFAULT '',
`amount` INT NOT NULL DEFAULT 0,
`status` VARCHAR(32) NOT NULL DEFAULT 'open',
`deleted` TINYINT(1) NOT NULL DEFAULT 0,
`created_by` CHAR(36) NULL,
`updated_by` CHAR(36) NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NULL,
PRIMARY KEY (`invoice_id`),
KEY `ix_invoice_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
],
'down' => [
"DROP TABLE IF EXISTS `invoice`",
],
];
- One logical DDL change per migration. MySQL/MariaDB auto-commit DDL, so a
CREATE/ALTERcan't roll back inside a transaction — a migration is recorded as applied only after all its statements succeed, and if one half-applies you fix forward. - The runner (
Tiger_Db_Migrator) discovers migrations across core, app (application/migrations), and every module'smigrations/dir, merges them into one ascending-by-version sequence, and records applied versions intiger_migrationso nothing runs twice. - A step may also be a callable
function ($db) { … }for a data migration SQL can't express cleanly (e.g. transforming a column across rows) — the string-vs-callable split is by type, so a SQL string is never mistaken for a function.
Run them from the console:
vendor/bin/tiger migrate # apply all pending (core + app + modules)
vendor/bin/tiger migrate:status # applied [x] / pending [ ]
vendor/bin/tiger migrate:rollback 1 # reverse the last n (default 1)
New module migrations go in application/modules/<name>/migrations/ and are picked up by a bare migrate. See the console for the full command list, and configuration for how the DB adapter is wired.