The slowest part of adding a feature is rarely the feature. It is the ceremony around the data: write a migration, name it, define up and down, run it, discover you needed one more column, write another migration, remember to run it on staging, watch it fail on production because someone else's migration landed first.

RedBeanPHP — the ORM under Tiknix — removes that ceremony during development by inverting the relationship. The database follows your code. You store an object with a new property; the column appears. You store a new kind of object; the table appears.

What this looks like in practice

use \app\Bean;

// The 'project' table does not exist yet. It will in a moment.
$project = Bean::dispense('project');
$project->name        = 'Website redesign';
$project->status      = 'active';
$project->budgetCents = 250000;
$project->createdAt   = date('Y-m-d H:i:s');

Bean::store($project);   // table created, columns inferred and typed

No schema file, no migration, no CREATE TABLE. RedBean inspected the values, chose column types, created the table, and inserted the row. Add $project->ownerEmail tomorrow and the column shows up tomorrow.

Reads and writes stay at the same altitude:

$project = Bean::load('project', $id);
$project->status = 'archived';
Bean::store($project);

$active = Bean::find('project', 'status = ? ORDER BY created_at DESC', ['active']);
$count  = Bean::count('project', 'status = ?', ['active']);

Bean::trash($project);

Note the parameter binding. Bean queries are parameterized the same way a hand-written PDO statement would be, so the convenience does not cost you SQL injection protection.

Relations without foreign-key bookkeeping

RedBean models one-to-many with own*List and many-to-many with shared*List. The link tables and foreign keys are created and maintained for you:

// One member has many API keys — the FK is set on store()
$member = Bean::load('member', $memberId);
$key    = Bean::dispense('apikey');
$key->name = 'Deploy key';
$member->ownApikeyList[] = $key;
Bean::store($member);          // saves both

// Reading back, ordered
$keys = $member->with(' ORDER BY created_at DESC ')->ownApikeyList;

// Filtered
$live = $member->withCondition(' is_active = ? ', [1])->ownApikeyList;

// Many-to-many: creates the product_tag link table automatically
$product->sharedTagList[] = $tag;
Bean::store($product);

The x prefix opts a relation into cascade delete — $contact->xownContactresponseList; before Bean::trash($contact) removes the responses along with the contact. Explicit, one character, and impossible to apply by accident.

The two naming rules that matter

RedBean's conventions are strict in exactly two places, and both are easy to get wrong once and never again:

  • Bean types are lowercase, no underscores. 'apikey', not 'apiKey' or 'api_key'. Tiknix ships lib/Bean.php, a thin wrapper that normalizes the name for you, which is why the examples above use Bean:: rather than R::.
  • Properties are camelCase; columns are snake_case. You write $bean->createdAt; the column is created_at. The translation is automatic and consistent in both directions.

Tiknix enforces both with a validation hook at .claude/hooks/validate-tiknix-php.py, which blocks invalid bean names outright and warns on the patterns that quietly defeat the ORM. That hook exists specifically because an AI assistant writing code fast will otherwise reproduce a plausible-looking mistake in fifteen files before anyone notices.

Fluid in development, frozen in production

Schema-follows-code is the right behavior while you are exploring and the wrong behavior on a live system — you do not want a typo'd property name silently creating a staus column at 2am.

That is what freeze mode is for. Frozen, RedBean stops altering the schema and throws instead. Your development loop keeps its speed; production keeps its guarantees. The discipline to adopt is straightforward:

  1. Develop fluid. Let the schema track the code while the shape is still moving.
  2. When the shape settles, capture it in a numbered bean seed (see below) rather than as a raw SQL dump — that is the artifact your deploy replays.
  3. Freeze in production, and apply schema changes by running the reseeder as part of the deploy.

You have not escaped migrations forever; you have deferred them to the point where you actually know what the schema should be. That is a meaningfully better trade than writing migrations for a design you are still guessing at.

Seeds and the reseeder — how schema actually ships

The "capture the schema" step is not a hand-written schema.sql that you keep in sync by hand. Tiknix builds the schema by running numbered bean seeds in services/Schema/Seeds/01_Member.php, 02_AuthControl.php, and so on. Each seed dispenses and stores the beans that define a table (and its starter rows), so RedBean creates the schema as a side effect of storing real objects.

One script runs them:

php scripts/reseed.php            # seed / top-up the configured database
php scripts/reseed.php --fresh    # drop every table first, then seed
DB_DSN=mysql://user@host/db php scripts/reseed.php   # same seeds, any backend

Three properties make this the right thing to wire into a deploy:

  • Idempotent. Seeds check before they create, so running the reseeder repeatedly is safe — it tops up what is missing and leaves the rest alone.
  • Dialect-agnostic. RedBean emits correct DDL for whatever it is connected to. The same seeds initialize SQLite locally and MySQL or Postgres on a deploy — there is no schema.sql dialect to juggle.
  • Cache-aware. reseed.php clears the permission cache after it runs, so new authcontrol rows take effect without a restart.

So the deploy cycle is: pull the code, run php scripts/reseed.php, done. The schema change traveled as a committed seed file, not as a SQL dump someone remembered to apply.

Seeds, not INSERTs

This is the rule that AI-assisted development makes easy to break: data and permissions ship as idempotent seeds, replayed by the reseeder, never as direct database writes made once on one machine.

A new route needs an authcontrol row. Starter data needs a seed. If it only exists because someone ran a query in a console, it does not exist on the next clone, the next workspace, or in production — and that class of "works on my install" bug is miserable to diagnose.

When to drop to SQL

Bean operations are mandatory for CRUD in Tiknix, and the reason is mechanical rather than aesthetic: FUSE models in models/ hook into store() and trash(). A raw R::exec('UPDATE ...') bypasses those hooks, skipping timestamps, validation, and any business logic the model owns. Do it consistently and the model layer becomes decorative.

Raw SQL earns its place for genuine aggregates, reporting queries, and atomic operations that beans cannot express:

// Legitimate: atomic increment without a read-modify-write race
R::exec('UPDATE member SET login_count = login_count + 1 WHERE id = ?', [$id]);

// Legitimate: a reporting query with joins and grouping
$rows = R::getAll('SELECT status, COUNT(*) AS n FROM project GROUP BY status');

The test is simple: if load + store would do it, use load + store.

Honest notes
  • Fluid mode in production is a real hazard. Unfrozen, a typo becomes a column and a bad value becomes a widened column type. Freeze before you take real traffic.
  • Inferred column types are conservative. RedBean widens types to fit what it has seen. For money, precise decimals, or specific integer widths, define the column yourself rather than letting inference guess.
  • find() returns id-keyed arrays. Not 0,1,2 — keyed by bean id. array_map() over one preserves those keys, and passing that straight into an IN (?,?) binding produces a genuinely confusing "column index out of range" error. Call array_values() at the source getter so every caller is safe. This one bites people; it is called out in the project's own standards for a reason.
  • Relation lists lazy-load. Convenient, and an easy path to N+1 queries inside a loop. The query cache masks a lot of it — measure rather than assume.
  • Bean properties are dynamic. Your editor cannot autocomplete them and a typo is not a compile error, it is a silently absent value. FUSE models with real accessor methods are worth adding once a bean type matters.