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
| Group | Columns | What 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
statusand 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_idset andpasswordempty. 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.
levelis 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
- One identity table. Resist the split; nullable columns beat orphan rows.
- One authorization input stored on that row, read by everything.
- One status field that is checked in the login query itself, not after it.
- A key/value settings table for everything that would otherwise become a column.
- A consistent
member_idon every owned resource, so ownership questions have one shape.
- Two overlapping status fields —
statusis the authoritative one. Bothstatusandis_activeexist 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 usestatus—is_activeis vestigial and no longer written on a fresh reseed. Standardize your own checks onstatus, and treat a strayis_activeas 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
levelalone — 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 (
xownvsown), and the right behavior — delete, anonymize, or retain — is a product decision. Decide it before someone asks to be forgotten.