Ask what the most important table in an application is and you will get answers about orders, documents, or whatever the product sells. The real answer is almost always the one that says who people are. Everything else hangs off it: ownership, permissions, audit trails, billing, notifications, sessions. Get it wrong early and you will be untangling it for years.

Tiknix has one such table — member — and it is worth reading column by column, because its shape encodes a set of decisions you would otherwise have to make yourself.

One table, one identity

GroupColumnsWhat it settles
Identity username, email, display_name, first_name, last_name, bio, avatar_url Login handle and public presentation, separated
Authorization level The single number every permission check reads
Lifecycle status, is_active, email_verified, created_at, updated_at Whether this account may be used at all
Password auth password, reset_token, reset_expires, needs_password_setup Hash plus the reset flow's state
Federated auth google_id OAuth identity linked to the same row
Second factor totp_secret, totp_enabled, totp_enabled_at, recovery_codes 2FA state, on the member rather than in a side table
Activity last_login, login_count Cheap, always-available signal of who is actually using the system

Notice what is not here: no separate user and profile split, no parallel admin table, no distinct oauth_identity table, no separate two_factor record. One row per human.

Why one table rather than several

The multi-table version looks cleaner on a whiteboard and is worse in practice, for a specific reason: every split creates a state where the halves disagree. An admin row with no user row. A profile orphaned by a deleted account. An OAuth identity pointing at a member who was disabled last week. Each of those is a real incident someone has debugged.

Concretely, the single-table design means:

  • One place to disable someone. Set status and they are out — password login, OAuth login, and API keys all resolve through the same row. There is no second system that keeps letting them in.
  • Google sign-in and password login are the same account. A member registered with Google has google_id set and password empty. The login path detects that and says "use the Google button" instead of failing with invalid credentials. The same person, one identity, two ways in.
  • Permissions have one input. level is an integer on the member row. Every check — dispatcher, controller, view — reads that one value. No role-resolution query, no cache of derived roles, no possibility of two subsystems computing different answers.

The cost is a wide table with nullable columns, some of which apply to only a fraction of accounts. That is a genuine normalization compromise, made deliberately, and it is the right one at this scale.

Settings: the extension point that isn't a schema change

Applications accumulate per-user state endlessly — a theme preference, a dismissed banner, a default filter, a feature grant. Adding a column for each is how a table gets to ninety columns.

Tiknix has a settings table instead: member_id, setting_key, setting_value. Two helpers make it a one-liner:

Flight::setSetting('dashboard.default_view', 'compact');
$view = Flight::getSetting('dashboard.default_view');   // current member

// Explicit member
$theme = Flight::getSetting('ui.theme', $memberId);

With no member id, it resolves the current member. And there is one detail worth knowing: system-wide settings are member-scoped too, owned by SYSTEM_ADMIN_ID. Passing member 0 is normalized to that account.

if ($memberId === 0) {
    $memberId = SYSTEM_ADMIN_ID;   // system settings are owned by the system admin
}

That is a small decision with a nice consequence: there is one settings mechanism, not a per-member one and a global one. The admin toggle for whether public registration is open lives in the same table as a member's theme preference — Flight::getSetting('registration_enabled') — with the same API and the same storage.

Feature flags reuse this too, as feature.<key> rows (see Growing Without Bloating the Core). A new capability that needs per-member state needs no new table and no migration — just a key naming convention.

The member is the hub of every relation

Because there is exactly one identity table, the ownership graph is unambiguous. In RedBeanPHP terms, the FUSE model Model_Member exists precisely so that relations work by name:

$member->ownApikeyList;      // API keys
$member->ownContactList;     // contact submissions
$member->ownSettingsList;    // preferences

And the wider schema follows the same rule — API keys, teams and team memberships, workbench tasks, connections, notifications, instances, pipeline runs all carry a member_id. "Who owns this?" always has an answer, and it is always the same kind of answer.

That consistency is what makes record-level access control tractable. When every resource identifies its owner the same way, a service like TaskAccessControl can ask one question about any of them.

The session is a snapshot — know this one

On successful login, the member row is exported into the session:

$_SESSION['member'] = $member->export();

Every subsequent request reads level, id, and username from that array without touching the database. Fast, and correct for the vast majority of requests — but it is a copy taken at login.

Change someone's level, disable their account, or revoke a feature, and their existing session may keep operating on the old snapshot until they log in again. If a privilege change must take effect immediately, re-read the member from the database in that code path or terminate the session. This is the single most common surprise in the whole membership design, and it is worth knowing before you discover it during an incident.

The pattern, generalized

  1. One identity table. Resist the split; nullable columns beat orphan rows.
  2. One authorization input stored on that row, read by everything.
  3. One status field that is checked in the login query itself, not after it.
  4. A key/value settings table for everything that would otherwise become a column.
  5. A consistent member_id on every owned resource, so ownership questions have one shape.
Honest notes
  • Two overlapping status fields — status is the authoritative one. Both status and is_active exist in older databases, a normal artifact of a schema that grew. The current member seed (services/Schema/Seeds/01_Member.php) and the login query both use statusis_active is vestigial and no longer written on a fresh reseed. Standardize your own checks on status, and treat a stray is_active as something to drop, not to maintain.
  • Settings values are strings. There is no typing or validation. Store JSON if you need structure, and validate on read — a setting written by an older version of your code is still there.
  • Settings are unindexed key lookups by convention. Fine at the volumes this is used for; if you push thousands of settings per member through hot paths, add an index on (member_id, setting_key).
  • The session snapshot bites eventually. See above. Decide deliberately which changes must be immediate.
  • One level per member means no per-context roles. A member cannot be an admin in one team and a viewer in another via level alone — that is what the team role system exists for. Do not try to encode context-dependent authority in the level.
  • Deleting a member is not a solved problem. Cascade rules are per-relation (xown vs own), and the right behavior — delete, anonymize, or retain — is a product decision. Decide it before someone asks to be forgotten.