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 shipslib/Bean.php, a thin wrapper that normalizes the name for you, which is why the examples above useBean::rather thanR::. -
Properties are camelCase; columns are snake_case. You write
$bean->createdAt; the column iscreated_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:
- Develop fluid. Let the schema track the code while the shape is still moving.
- 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.
- 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.sqldialect to juggle. - Cache-aware.
reseed.phpclears the permission cache after it runs, so newauthcontrolrows 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.
- 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. Not0,1,2— keyed by bean id.array_map()over one preserves those keys, and passing that straight into anIN (?,?)binding produces a genuinely confusing "column index out of range" error. Callarray_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.