Pramnos Schema Builder Guide¶
The Schema Builder (DDL — Data Definition Language) provides a programmatic interface for defining and modifying database schemas. It supports MySQL 8.0+, PostgreSQL 14+, and TimescaleDB with dialect-aware syntax.
Class: Pramnos\Database\SchemaBuilder
Entry point: $db->schemaBuilder() — returns a fresh builder bound to the current database connection.
Overview¶
The Schema Builder enables you to:
- Create and drop tables with proper type mapping
- Add, modify, and drop columns
- Define indexes, primary keys, and foreign keys
- Create views and stored procedures (dialect-specific)
- Manage TimescaleDB hypertables and time-series features
- Detect database capabilities and conditionally execute DDL
Entry point: $db->schema() — preferred alias. $db->schemaBuilder() also works.
Getting Started¶
Create a Table¶
$db = \Pramnos\Database\Database::getInstance();
$schema = $db->schemaBuilder();
$schema->create('users', function ($table) {
$table->id(); // BIGINT PRIMARY KEY AUTO_INCREMENT
$table->string('username', 50)->unique(); // VARCHAR(50) UNIQUE
$table->string('email')->unique(); // VARCHAR(255) UNIQUE
$table->string('password', 60); // VARCHAR(60) for hashed password
$table->timestamps(); // created_at, updated_at
$table->timestamp('last_login')->nullable(); // nullable timestamp
$table->boolean('active')->default(true); // BOOLEAN DEFAULT TRUE
$table->index('email'); // Email index for quick lookups
});
Modify an Existing Table¶
$schema->table('users', function ($table) {
$table->string('phone')->nullable(); // Add column
$table->dropColumn('legacy_field'); // Remove column
$table->changeColumn('username', 'string', 100); // Modify column type
$table->dropIndex('email'); // Remove index
});
Drop a Table¶
Column Types¶
The Schema Builder maps PHP type hints to database-native types:
| Method | MySQL | PostgreSQL | Notes |
|---|---|---|---|
id() |
BIGINT AUTO_INCREMENT | BIGSERIAL | Primary key |
string($name, $length = 255) |
VARCHAR(n) | VARCHAR(n) | |
integer($name) |
INT | INT | 32-bit |
bigInteger($name) |
BIGINT | BIGINT | 64-bit |
text($name) |
TEXT | TEXT | Large text |
boolean($name) |
BOOLEAN | BOOLEAN | 0/1 or true/false |
float($name, $precision, $scale) |
FLOAT | REAL | |
decimal($name, $precision, $scale) |
DECIMAL(p,s) | NUMERIC(p,s) | |
date($name) |
DATE | DATE | |
time($name) |
TIME | TIME | |
dateTime($name) |
DATETIME | TIMESTAMP | |
timestamp($name) |
TIMESTAMP | TIMESTAMP | Server timestamp |
json($name) |
JSON | JSON / JSONB | |
uuid($name) |
CHAR(36) | UUID | |
enum($name, $values) |
ENUM | Enum (PG only) |
Column Modifiers¶
$table->string('email')
->nullable() // allow NULL
->default('guest') // set DEFAULT
->unique() // UNIQUE constraint
->index() // create index
->after('username') // column ordering (MySQL)
->first() // place at start (MySQL)
->comment('User email'); // column comment
Indexes¶
$table->primary('userid'); // PRIMARY KEY
$table->unique('email'); // UNIQUE index
$table->index('created_at'); // Regular index
$table->fullText('description'); // Full-text index (MySQL)
$table->spatialIndex('location'); // Spatial index (MySQL)
// Composite indexes
$table->index(['country', 'city']);
$table->unique(['userid', 'token']);
Foreign Keys¶
$table->unsignedBigInteger('author_id');
$table->foreign('author_id')
->references('userid')
->on('users')
->onDelete('CASCADE') // CASCADE, RESTRICT, SET NULL, NO ACTION
->onUpdate('CASCADE');
Timestamps & Tracking¶
$table->timestamps(); // created_at, updated_at (both TIMESTAMP)
$table->softDeletes(); // deleted_at (nullable TIMESTAMP)
$table->userstamps(); // created_by, updated_by (user IDs)
$table->rememberToken(); // For API tokens (String)
Conditional DDL (DatabaseCapabilities)¶
Execute DDL conditionally based on database features:
$caps = new \Pramnos\Database\DatabaseCapabilities($db);
$caps->ifCapable(
\Pramnos\Database\DatabaseCapabilities::FEATURE_TIMESCALEDB,
function () use ($schema) {
// TimescaleDB only — create hypertable
$schema->create('metrics', function ($table) {
$table->id();
$table->timestamp('time')->index();
$table->float('value');
});
$schema->createHypertable('metrics', 'time');
},
function () use ($schema) {
// Plain PostgreSQL or MySQL fallback
$schema->create('metrics', function ($table) {
$table->id();
$table->timestamp('time')->index();
$table->float('value');
});
}
);
TimescaleDB Features¶
Create Hypertables¶
Time Bucketing¶
// Dialect-transparent time_bucket()
$result = $db->queryBuilder()
->select([
$db->schemaBuilder()->timeBucket('1 hour', 'time'),
$db->queryBuilder()->raw('AVG(value) as avg_value'),
])
->from('metrics')
->groupBy(1)
->get();
Retention Policies¶
Continuous Aggregates¶
$schema->createContinuousAggregate('metrics_hourly', function ($q) {
$q->select([
$db->schemaBuilder()->timeBucket('1 hour', 'time'),
$q->raw('AVG(value) as avg_value'),
])
->from('metrics')
->groupBy(1);
});
Views¶
// Create a view from a QueryBuilder
$schema->createView('active_users', function () use ($db) {
return $db->queryBuilder()
->select('userid', 'username', 'email')
->from('users')
->where('active', 1);
});
// Drop a view
$schema->dropView('active_users');
// Materialized view (PostgreSQL)
$schema->createMaterializedView('user_stats', function () use ($db) {
return $db->queryBuilder()
->select('userid', $db->queryBuilder()->raw('COUNT(*) as post_count'))
->from('posts')
->groupBy('userid');
});
Complete Column Type Reference¶
| Blueprint method | MySQL | PostgreSQL |
|---|---|---|
tinyInteger(name) |
TINYINT |
SMALLINT |
smallInteger(name) |
SMALLINT |
SMALLINT |
integer(name) |
INT |
INTEGER |
bigInteger(name) |
BIGINT |
BIGINT |
unsignedInteger(name) |
INT UNSIGNED |
INTEGER |
unsignedBigInteger(name) |
BIGINT UNSIGNED |
BIGINT |
increments(name) |
INT UNSIGNED AUTO_INCREMENT PK |
SERIAL PK |
bigIncrements(name) |
BIGINT UNSIGNED AUTO_INCREMENT PK |
BIGSERIAL PK |
char(name, length) |
CHAR(n) |
CHAR(n) |
string(name, length=255) |
VARCHAR(n) |
VARCHAR(n) |
text(name) |
TEXT |
TEXT |
mediumText(name) |
MEDIUMTEXT |
TEXT |
longText(name) |
LONGTEXT |
TEXT |
float(name) |
FLOAT |
REAL |
double(name) |
DOUBLE |
DOUBLE PRECISION |
decimal(name, total, places) |
DECIMAL(p,s) |
DECIMAL(p,s) |
boolean(name) |
TINYINT(1) |
BOOLEAN |
date(name) |
DATE |
DATE |
time(name) |
TIME |
TIME |
dateTime(name) |
DATETIME |
TIMESTAMP |
timestamp(name) |
TIMESTAMP |
TIMESTAMP |
timestampTz(name) |
TIMESTAMP |
TIMESTAMPTZ |
year(name) |
YEAR |
INTEGER |
binary(name) |
BLOB |
BYTEA |
json(name) |
JSON |
JSON |
jsonb(name) |
JSON (fallback) |
JSONB |
uuid(name) |
CHAR(36) |
UUID |
enum(name, values[]) |
ENUM('v1','v2') |
VARCHAR(n) CHECK (col IN (...)) |
geometry(name) |
GEOMETRY |
GEOMETRY |
timestamps() |
nullable created_at + updated_at TIMESTAMP |
same |
timestampsTz() |
nullable TIMESTAMP | nullable TIMESTAMPTZ |
softDeletes() |
nullable deleted_at TIMESTAMP |
same |
ALTER TABLE¶
$schema->alterTable('#PREFIX#users', function ($table) {
// Add columns
$table->string('phone', 20)->nullable()->after('email');
$table->integer('login_count')->default(0);
// Drop columns
$table->dropColumn('old_field');
$table->dropColumn(['field_a', 'field_b']);
// Rename a column
$table->renameColumn('old_name', 'new_name');
// Modify an existing column (change type and/or attributes)
$table->modifyColumn('bio', 'text'); // type only
$table->modifyColumn('status', 'string', ['length' => 100]) // type + length
->nullable(false)
->default('active');
// Drop an index
$table->dropIndex('idx_old_name');
// Drop a foreign key
$table->dropForeign('fk_old_constraint');
// Add a new unique constraint
$table->unique('email', 'uq_email');
// Add a new index
$table->index(['country', 'city'], 'idx_location');
// Add a new foreign key
$table->foreign('new_col')->references('id')->on('other_table')->nullOnDelete();
});
modifyColumn() Dialect Behaviour¶
| Dialect | Generated SQL |
|---|---|
| MySQL | ALTER TABLE t MODIFY COLUMN name type [modifiers] — single statement |
| PostgreSQL | Up to 3 separate ALTER COLUMN statements: TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT |
| TimescaleDB | Same as PostgreSQL |
Moving a table into a schema¶
Not renameTable(). PostgreSQL's ALTER TABLE … RENAME TO takes a bare name — it cannot
move a table between schemas, and handing it a qualified one is a syntax error rather than a
move. The statement for that is SET SCHEMA, and it is a catalogue update: no row is copied,
whatever the table holds. On MySQL a schema is flattened into the table name, so the same
intention compiles to a rename — also a catalogue update.
It answers false rather than raising in the three states a migration meets:
| State | Answer |
|---|---|
| the table is not there | false — an installation that never had it |
| it is already in the target schema | false — an installation that has already migrated |
| something is already at the destination | false, and the source is untouched |
The third one is why it checks: a half-finished manual move leaves a table of the same name in both schemas, and overwriting the destination would destroy the rows somebody had already moved.
This is what a migration uses when a table turns out to be in the wrong place after it has been deployed. Where nothing has been deployed yet, correct the migration that creates it instead — a move is a step every installation then has to run for ever.
View Operations¶
// Regular views (all backends)
$schema->createView('v_active_users', 'SELECT * FROM users WHERE active = 1');
$schema->createOrReplaceView('v_active_users', 'SELECT * FROM users WHERE active = 1');
$schema->dropView('v_active_users'); // IF EXISTS by default
$schema->dropView('v_active_users', false); // strict — error if not found
// Materialized views (PostgreSQL/TimescaleDB)
// On MySQL: falls back silently to a regular VIEW
$schema->createMaterializedView('mv_stats', 'SELECT region, COUNT(*) FROM orders GROUP BY region');
$schema->refreshMaterializedView('mv_stats');
$schema->refreshMaterializedView('mv_stats', true); // CONCURRENTLY (PG: allows concurrent reads)
$schema->dropMaterializedView('mv_stats');
Introspection¶
if ($schema->hasTable('users')) {
// table exists
}
if ($schema->hasColumn('users', 'email')) {
// column exists
}
if ($schema->hasIndex('users', 'idx_users_email')) {
// index exists
}
hasIndex() matches on the index name, not on its columns. Two indexes over the
same columns are legal, so a guard asking "is there an index on this column" would
skip creating the one a migration needs because an unrelated one happens to cover the
same ground. A constraint-backed index — a UNIQUE constraint, a primary key — counts
as existing, because what a caller is really asking is whether creating it would
collide.
It is what makes an index idempotent in a migration:
if (!$schema->hasIndex('usertokens', 'idx_usertokens_token_lookup')) {
$schema->alterTable('usertokens', function ($table) {
$table->unique(['token_lookup'], 'idx_usertokens_token_lookup');
});
}
Before it existed the alternatives were catching the driver's duplicate-index error or leaving the guard out, which are two ways of writing "I could not ask".
Each driver asks its own question, and PostgreSQL asks a different one. MySQL reads
information_schema.statistics; PostgreSQL reads pg_indexes, because the standard has no index
view — indexes are not part of it — and information_schema.statistics does not exist there at all.
A grammar for a driver the framework does not know inherits the MySQL-shaped query, on the
reasoning that an unknown driver is more likely to resemble that than nothing.
Two consequences worth knowing when a guard answers wrongly:
- A qualified name is split on PostgreSQL.
authserver.usertokensbecomes schemaauthserver, tableusertokens— without that,tablename = 'authserver.usertokens'matches nothing and every index on the framework's own tables reads as absent. - With no schema, PostgreSQL excludes
pg_catalogandinformation_schemarather than searching everything. Those carry thousands of indexes, and a name colliding with one of them would answer yes for a table your project owns.
TimescaleDB Operations¶
All TimescaleDB methods return false silently on non-TimescaleDB backends:
$schema->createHypertable('events', 'created_at', [
'chunk_time_interval' => '7 days',
]);
$schema->addSpaceDimension('events', 'device_id', 4);
$schema->enableCompression('events', [
'segmentby' => 'device_id',
'orderby' => 'created_at DESC',
]);
$schema->addCompressionPolicy('events', '60 days');
$schema->addRetentionPolicy('events', '365 days');
// Continuous aggregate — TimescaleDB native / PG MATERIALIZED VIEW / MySQL VIEW
$schema->createContinuousAggregate(
'hourly_events',
"SELECT time_bucket('1 hour', created_at) AS bucket, COUNT(*) FROM events GROUP BY bucket"
);
Capability-Conditional DDL¶
ifCapable() on SchemaBuilder runs a callback only when the backend supports the capability.
$schema->createTable('#PREFIX#events', function ($table) {
$table->bigIncrements('eventid');
$table->string('action', 64);
$table->integer('userid')->nullable();
$table->timestampTz('action_time')->default(new \Pramnos\Database\Expression('NOW()'));
});
$schema->ifCapable(\Pramnos\Database\DatabaseCapabilities::TIMESCALEDB,
function (\Pramnos\Database\SchemaBuilder $schema) {
$schema->createHypertable('#PREFIX#events', 'action_time', [
'chunk_time_interval' => '14 days',
]);
$schema->enableCompression('#PREFIX#events', ['segmentby' => 'action']);
$schema->addCompressionPolicy('#PREFIX#events', '60 days');
}
// No fallback — stays as regular table on MySQL and plain PostgreSQL
);
// With fallback
$schema->ifCapable(
\Pramnos\Database\DatabaseCapabilities::MATERIALIZED_VIEWS,
fn($s) => $s->createMaterializedView('mv_stats', $query),
fn($s) => $s->createView('mv_stats', $query) // MySQL: regular VIEW
);
API: ifCapable(string $capability, callable $callback, ?callable $fallback = null): mixed
The $callback receives SchemaBuilder $schema (not Database).
DatabaseCapabilities Constants¶
| Constant | True when |
|---|---|
DatabaseCapabilities::TIMESCALEDB |
TimescaleDB |
DatabaseCapabilities::MATERIALIZED_VIEWS |
PostgreSQL or TimescaleDB |
DatabaseCapabilities::ENUMS |
PostgreSQL (native CREATE TYPE … AS ENUM) |
DatabaseCapabilities::JSONB |
PostgreSQL |
DatabaseCapabilities::SEQUENCES |
PostgreSQL, MariaDB ≥ 10.3 |
DatabaseCapabilities::RETURNING |
PostgreSQL, MariaDB ≥ 10.5 |
DatabaseCapabilities::NATIVE_JSON |
PostgreSQL, MySQL ≥ 5.7.8 (not MariaDB) |
DatabaseCapabilities::CHECK_CONSTRAINTS |
PostgreSQL, MariaDB ≥ 10.2, MySQL ≥ 8.0.16 |
Convenience methods: hasMaterializedViews(): bool, hasEnums(): bool, hasTimescaleDB(): bool,
hasSequences(): bool, hasReturning(): bool, hasNativeJson(): bool, hasCheckConstraints(): bool.
Engine, flavor and version¶
isMySQL() means "the MySQL family" and is therefore true on MariaDB as well. Every
capability gate in the framework reads it as "compile MySQL-compatible grammar" — backtick
quoting, information_schema introspection, AUTO_INCREMENT — all of which MariaDB does.
isMariaDB() is the narrowing predicate: true only when the MySQL-family server is
specifically MariaDB. It implies isMySQL() === true.
$caps = $db->schema()->getCapabilities();
$caps->isMySQL(); // true on MySQL *and* MariaDB
$caps->isMariaDB(); // true only on MariaDB
$caps->getVersion(); // "10.11.6" — normalised, vendor noise stripped
$caps->atLeast('10.5') // version-aware gating
The flavor is detected from the live server version string, never from configuration:
MariaDB installations are configured as type = 'mysql' and must stay that way. Detection
degrades safely — an unconnected Database reports an unknown version, and every
version-gated capability then answers false, so new behaviour is always opt-in.
Prefer asking the feature question over the identity question:
// Good — works on PostgreSQL and MariaDB 10.3+, no-ops elsewhere
if ($caps->has(DatabaseCapabilities::SEQUENCES)) { … }
// Fragile — says nothing about what the server can actually do
if ($caps->isPostgreSQL()) { … }
Triggers¶
$schema = $db->schema();
// MySQL trigger
$schema->createTrigger(
'trg_log_insert',
'orders',
'AFTER',
'INSERT',
"BEGIN
INSERT INTO order_audit (order_id, action, created_at)
VALUES (NEW.id, 'insert', NOW());
END"
);
// PostgreSQL trigger (body references a trigger function)
$schema->createTrigger(
'trg_log_insert',
'orders',
'AFTER',
'INSERT',
'EXECUTE FUNCTION log_order_insert()'
);
// Drop with IF EXISTS guard
$schema->dropTrigger('trg_log_insert', 'orders', ifExists: true);
| MySQL | PostgreSQL | |
|---|---|---|
| Trigger body | Inline BEGIN … END PL/SQL |
EXECUTE FUNCTION fn_name() |
| DDL verb | CREATE TRIGGER |
CREATE OR REPLACE TRIGGER |
| Drop syntax | DROP TRIGGER [IF EXISTS] name |
DROP TRIGGER [IF EXISTS] name ON table |
Sequences (PostgreSQL and MariaDB 10.3+)¶
Sequences are monotonically increasing integer generators. They are supported on PostgreSQL and
on MariaDB 10.3+, where the framework selects a MariaDB DDL grammar that emits real
CREATE SEQUENCE / NEXTVAL() / SETVAL() / DROP SEQUENCE statements.
On Oracle MySQL — and on a MariaDB older than 10.3, which has no sequence objects — all methods
are silent no-ops and return 0 as a sentinel.
$schema = $db->schema();
// Create a sequence
$schema->createSequence(
'order_seq',
start: 1000,
increment: 5,
);
// Advance and read
$id = $schema->nextVal('order_seq');
// Reposition
$schema->setVal('order_seq', 500, isCalled: false);
$next = $schema->nextVal('order_seq'); // → 500 (exact value)
// Drop
$schema->dropSequence('order_seq', ifExists: true);
Batch ID reservation pattern:
$schema->setVal('order_seq', $currentMax + 1000, isCalled: false);
// Assign IDs locally without DB round-trips for the next 1000 records
MySQL compatibility:
$id = $schema->nextVal('order_seq');
if ($id === 0) {
// No sequence objects on this server — fall back to AUTO_INCREMENT
$db->query("INSERT INTO orders ...");
$id = $db->insertId();
}
Or ask before you act, which is clearer than reading a sentinel:
if ($schema->getCapabilities()->hasSequences()) {
$id = $schema->nextVal('order_seq');
} else {
// AUTO_INCREMENT path
}
Note the dialect differences the grammar hides for you: MariaDB spells the negative cycle
option NOCYCLE (one word, not PostgreSQL's NO CYCLE), and its NEXTVAL/SETVAL take a
bare identifier rather than PostgreSQL's string literal. MariaDB's third SETVAL argument is
named is_used but carries the same meaning as PostgreSQL's is_called.
Time Bucketing¶
QueryBuilder::timeBucket(string $interval, string|Expression $column): Expression returns a dialect-appropriate SQL expression:
$qb = $db->queryBuilder();
$bucket = $qb->timeBucket('15 minutes', 'recorded_at');
$result = $qb
->select([$bucket . ' AS bucket', 'AVG(value) AS avg_value'])
->from('sensor_readings')
->groupBy([$bucket])
->orderBy($bucket, 'asc')
->get();
| Interval | TimescaleDB | PostgreSQL | MySQL |
|---|---|---|---|
'1 hour' |
time_bucket('1 hour', col) |
date_trunc('hour', col) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col)/3600)*3600) |
'15 minutes' |
time_bucket('15 minutes', col) |
to_timestamp(floor(extract(epoch from col)/900)*900) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col)/900)*900) |
'1 day' |
time_bucket('1 day', col) |
date_trunc('day', col) |
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(col)/86400)*86400) |
'1 month' |
time_bucket('1 month', col) |
date_trunc('month', col) |
DATE_FORMAT(col, '%Y-%m-01') |
Grammar Injection¶
$schema->setGrammar(new \Pramnos\Database\Grammar\PostgreSQLSchemaGrammar());
$grammar = $schema->getGrammar(); // SchemaGrammarInterface
Migration-Support Helpers¶
These methods are primarily used inside Migration subclasses:
// Execute a raw DDL statement
$this->DB()->statement("CREATE OR REPLACE FUNCTION ...");
// Execute a SELECT and return the first row
$row = $this->DB()->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_name = ?",
['users']
);
// Get the PDO-compatible driver name
if ($this->DB()->getDriverName() === 'pgsql') { /* PostgreSQL branch */ }
// Get capabilities
if ($this->DB()->capabilities()->hasTimescaleDB()) { /* hypertable branch */ }
// Schema-qualified builder
$this->schema('public')->create('users', function ($table) { ... });
Backward Compatibility¶
$db->schemaBuilder()continues to work;$db->schema()is the preferred alias.SchemaBuilderpreviously existed as a stub. All original methods (create(),drop(),truncate(),createHypertable(),addRetentionPolicy()) still exist with the same signatures.ColumnDefinition,ForeignKeyDefinition, andBlueprintare new — purely additive.Blueprint::addColumn()wasprotected; it is nowpublic.