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 17+ and TimescaleDB 2.26.4+ with dialect-aware syntax — the versions the suite runs against.
Class: Pramnos\Database\SchemaBuilder
Entry point: $db->schema() — returns a builder bound to the current database connection. $db->schemaBuilder() returns the same kind of builder.
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, materialized views, triggers and sequences (dialect-specific)
- Manage TimescaleDB hypertables and time-series features
- Detect database capabilities and conditionally execute DDL
Getting Started¶
Create a Table¶
$db = \Pramnos\Database\Database::getInstance();
$schema = $db->schema();
$schema->create('users', function ($table) {
$table->bigIncrements('userid'); // BIGINT UNSIGNED AUTO_INCREMENT PK / BIGSERIAL
$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(); // nullable created_at, updated_at
$table->timestamp('last_login')->nullable(); // nullable timestamp
$table->boolean('active')->default(true); // TINYINT(1) / BOOLEAN
$table->index('email'); // named users_email_index
});
Modify an Existing Table¶
$schema->table('users', function ($table) {
$table->string('phone')->nullable(); // Add column
$table->dropColumn('obsolete_field'); // Remove column
$table->modifyColumn('username', 'string', ['length' => 100]); // Change column type
$table->dropIndex('users_email_index'); // Remove index by name
});
table() is an alias of alterTable(), and create() of createTable().
Drop a Table¶
Column Types¶
The most common types — the complete reference lists every one:
| Method | MySQL | PostgreSQL | Notes |
|---|---|---|---|
increments($name) / bigIncrements($name) |
INT / BIGINT UNSIGNED AUTO_INCREMENT | SERIAL / BIGSERIAL | Primary key |
string($name, $length = 255) |
VARCHAR(n) | VARCHAR(n) | |
integer($name) |
INT | INTEGER | 32-bit |
bigInteger($name) |
BIGINT | BIGINT | 64-bit |
text($name) |
TEXT | TEXT | Large text |
boolean($name) |
TINYINT(1) | BOOLEAN | |
float($name, $total = 8, $places = 2) |
FLOAT | REAL | |
decimal($name, $total = 8, $places = 2) |
DECIMAL(p, s) | DECIMAL(p, s) | |
date($name) |
DATE | DATE | |
time($name) |
TIME | TIME | |
dateTime($name) |
DATETIME | TIMESTAMP | |
timestamp($name) |
TIMESTAMP | TIMESTAMP | |
json($name) / jsonb($name) |
JSON | JSON / JSONB | |
uuid($name) |
CHAR(36) | UUID | |
enum($name, $values) |
ENUM('a', 'b') | VARCHAR + CHECK | enumType() for a native PostgreSQL enum |
Column Modifiers¶
$table->string('email')
->nullable() // allow NULL
->default('guest') // set DEFAULT
->unique() // UNIQUE constraint
->after('username') // column ordering (MySQL)
->comment('User email'); // column comment
$table->integer('count')->unsigned(); // UNSIGNED (MySQL)
$table->timestamp('seen_at')->useCurrent(); // DEFAULT CURRENT_TIMESTAMP
$table->integer('score')->check('score >= 0'); // CHECK constraint
first(), autoIncrement(), primary(), storedAs(), virtualAs(), charset() and
collation() are also available on a column.
charset() and collation() are written on MySQL and MariaDB (CHARACTER SET … COLLATE …
after the type) and ignored on PostgreSQL. A column that holds emoji under a unique key takes
utf8mb4_bin: the tables' default, utf8mb4_unicode_ci, compares every character outside the
Basic Multilingual Plane as equal, so 😍 and 💝 are the same value to it and the second insert
is refused or ignored.
Indexes¶
$table->primary('userid'); // PRIMARY KEY
$table->unique('email'); // UNIQUE index
$table->index('created_at'); // Regular index
// Composite indexes, with an explicit name
$table->index(['country', 'city'], 'idx_location');
$table->unique(['userid', 'token'], 'uq_user_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');
cascadeOnDelete(), cascadeOnUpdate(), nullOnDelete() and noActionOnDelete() are
shorthands, and name() sets the constraint name.
The referenced table is named as the one being built is: ->on('users') references the
installation's prefixed users, and ->on('authserver.roles') the {prefix}authserver_roles a
MySQL database has. On MySQL the two columns must have the same type, sign included — an
increments() is INT UNSIGNED, so the column referencing it is ->integer(…)->unsigned().
Timestamps¶
$table->timestamps(); // nullable created_at, updated_at (TIMESTAMP)
$table->timestampsTz(); // the same as TIMESTAMPTZ on PostgreSQL
$table->softDeletes(); // nullable deleted_at TIMESTAMP
$table->softDeletesTz(); // nullable deleted_at TIMESTAMPTZ
Conditional DDL (DatabaseCapabilities)¶
Execute DDL conditionally based on database features:
$schema->create('metrics', function ($table) {
$table->bigIncrements('id');
$table->timestamp('time');
$table->float('value');
$table->index('time');
});
$schema->ifCapable(
\Pramnos\Database\DatabaseCapabilities::TIMESCALEDB,
function (\Pramnos\Database\SchemaBuilder $schema) {
// TimescaleDB only
$schema->createHypertable('metrics', 'time');
},
function () {
// Plain PostgreSQL or MySQL: stays a regular table
}
);
See Capability-Conditional DDL for ifCapable() against
whenCapable().
TimescaleDB Features¶
Create Hypertables¶
Time Bucketing¶
// Dialect-transparent time_bucket() — a QueryBuilder method
$qb = $db->queryBuilder();
$bucket = $qb->timeBucket('1 hour', 'time');
$result = $qb
->select([$bucket . ' AS bucket', 'AVG(value) AS avg_value'])
->from('metrics')
->groupBy([$bucket])
->get();
Retention Policies¶
$schema->addRetentionPolicy('metrics', '30 days', 'time'); // third argument: time column, default 'created_at'
Continuous Aggregates¶
The body is a SQL string:
$schema->createContinuousAggregate(
'metrics_hourly',
"SELECT time_bucket('1 hour', time) AS bucket, AVG(value) AS avg_value
FROM metrics GROUP BY bucket"
);
See TimescaleDB Operations for what is materialised on creation.
Views¶
Views take their body as a SQL string; a builder query becomes one with toSql() only when
it has no bindings, so write the SQL directly:
$schema->createView('active_users',
'SELECT userid, username, email FROM users WHERE active = 1');
$schema->dropView('active_users');
// Materialized view (PostgreSQL; a regular VIEW on MySQL)
$schema->createMaterializedView('user_stats',
'SELECT userid, COUNT(*) AS post_count FROM posts GROUP BY 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 |
Dropping a CHECK constraint¶
DROP CONSTRAINT, which PostgreSQL, MySQL 8.0.19+ and MariaDB share — DROP CHECK is MySQL's
alone. On MariaDB a CHECK written on a column belongs to the column and is named after it; no
DROP CONSTRAINT reaches it, so the column is redefined without it, keeping its type,
nullability, default and comment. A CHECK a later migration may need to drop is better named and
table-level from the start.
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');
Which kind of relation owns a name¶
dropView() and dropMaterializedView() are not interchangeable. PostgreSQL refuses
DROP VIEW on a materialized view outright:
ERROR: "usage_statistics" is not a view
HINT: Use DROP MATERIALIZED VIEW to remove a materialized view.
hasView() cannot tell you which you have — it answers yes for a plain view, a
materialized view and a TimescaleDB continuous aggregate alike, which is the right
question only when all you need to know is whether a SELECT will be answered.
getRelationKind() is the one to ask before a drop:
$kind = $schema->getRelationKind('applications.usage_statistics');
// null → nothing owns the name, free to create
// 'v' → a plain view (a continuous aggregate reports 'v' too)
// 'm' → a materialized view
// 'r' → an ordinary table ('p' partitioned, 'f' foreign)
if ($kind === 'v') {
$schema->dropView('applications.usage_statistics');
}
The letters are PostgreSQL's own pg_class.relkind values. MySQL has neither
materialized views nor a relkind, so it answers with the only two it can distinguish,
v and r.
The reason to reach for it is rarely a re-run — a view the framework created is a view it can replace. It is a different owner: an application that made the same name a materialized view because it wanted a cached read, and refreshes it on a schedule of its own. Dropping that relation swaps a cached read for a full aggregation underneath a running installation, and leaves whatever refreshes it pointed at nothing. Neither belongs in a migration, so check the kind and leave what you did not create.
Introspection¶
if ($schema->hasTable('users')) {
// table exists
}
if ($schema->hasColumn('users', 'email')) {
// column exists
}
if ($schema->hasIndex('users', 'idx_users_email')) {
// index exists
}
hasTable() and hasColumn() remember a table or column they found, per connection, for
the life of the process. Code that guards on an optional table can ask on every call and pay
one catalogue query a process. A missing table is never remembered, so one a migration creates
is seen at once. The answers are forgotten:
- by every DDL method of the builder;
- by any DROP, ALTER or RENAME the same connection runs, hand-written included;
- by the migration runner, before and after each migration.
A table dropped by another connection or process is caught three ways: - a query that fails because the table is not there (MySQL 1146, PostgreSQL 42P01) makes the connection forget, so the process is wrong once and right from then on; - a migration moves the schema generation, which restarts the orchestrator's daemons and makes any worker loop forget between tasks — see the Workers and Daemons Guide; - and by hand, after DDL through a second connection in the same process:
\Pramnos\Database\SchemaBuilder::forgetWhatExists(); // every connection
\Pramnos\Database\SchemaBuilder::forgetWhatExists($db); // one
$schema->columnLength('usertokens', 'token'); // 255 for a varchar(255); 0 for text, a number, or no answer
// By what they cover, not by name: a table older than the framework holds the same index as
// `tokenid` and the same key as `usertokens_ibfk_1`, and a check by name adds a second one.
$schema->indexColumns('tokenactions'); // ['tokenid' => ['tokenid'], …]
$schema->hasIndexOn('tokenactions', ['action_time', 'method']); // any name, these columns in this order
$schema->hasForeignKeyOn('usertokens', 'applicationid', 'applications');
if ($schema->hasView('reports.daily')) {
// a plain view, a materialized view or a continuous aggregate — any of the three
}
$schema->getRelationKind('reports.daily'); // 'v' | 'm' | 'r' | null — which one it is
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');
});
}
Without it the alternatives are 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"
);
Not every aggregate can be one. TimescaleDB requires a continuous aggregate to select
from a hypertable, to bucket on that hypertable's time dimension with time_bucket(), and
to stay inside the SQL it can maintain incrementally — so a query built on CTEs, or over an
ordinary table, is refused at CREATE rather than merely being slow. The boundary, the
exact refusals, a qualifying/non-qualifying pair, and the materialised-view-plus-add_job
fallback with the three costs it carries are in
Caching an aggregate: continuous aggregate, or materialised view.
Read it before designing a rollup, not after the refusal.
What is materialised when it is created. The aggregate is created WITH NO DATA and then
refreshed up to now(). TimescaleDB materialises only buckets that lie wholly inside a refresh
window, so every closed bucket is filled in and the open one is left live: a real-time
aggregate (timescaledb.materialized_only = false) goes on showing rows that arrive in it.
PostgreSQL's default, WITH DATA, would materialise the open bucket as well and move the
watermark to its end — freezing it at the moment of creation until it closed, which the refresh
policy cannot undo because it refreshes closed buckets only. Over an integer time column the
initial refresh is refused (it takes an integer window); the aggregate is created empty, the
migrations log says so, and its refresh policy fills it in.
createContinuousAggregate() is safe to call against a name that is already taken: it
returns without issuing any DDL and writes a line to the migrations log saying the
existing definition was kept.
That guard is there because neither CREATE MATERIALIZED VIEW nor MySQL's CREATE VIEW
is idempotent. Without it the call raises continuous aggregate "…" already exists and
takes the surrounding migration with it — on an installation whose only difference is
that it built the same rollup first, which is a common thing for an application to have
done.
Two consequences to know:
- The definition in force is the one that was already there. A changed aggregate body does not land on an installation that has the old one; dropping and rebuilding it is a deliberate act, because on TimescaleDB it discards materialised history.
- The log line is the only notice. It is written to the
migrationschannel, not raised, so a migration that must know should askhasView()itself first.
Creating an aggregate does not schedule its refresh. addContinuousAggregatePolicy()
does that — a native job on TimescaleDB, a row in pramnos.framework_policies on every
other backend — and Pramnos\Database\ContinuousAggregateRegistry is where the
framework declares the parameters once, so the creating migration and a later repair
use the same ones. See
Keeping a rollup refreshed:
an unrefreshed materialised view is frozen at the moment it was created, which is worse
than a missing one because it still answers.
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).
ifCapable() or whenCapable()¶
Inside a migration the two differ in what happens when the capability is absent:
| Absent, no fallback | Use when | |
|---|---|---|
ifCapable() |
The migration is declined — recorded, reported, retried by the next migrate |
The capability is the work: a continuous aggregate, a conversion that is all the migration does |
whenCapable() |
Nothing, and the migration is applied | An extra on work already done that something else repairs later — a table's hypertable, which HypertableRegistry declares and timescale:ensure reapplies |
A guard on an engine — ENGINE_POSTGRESQL, ENGINE_MYSQL, ENGINE_MARIADB — never declines:
the migration is choosing its dialect, and a MariaDB will not become PostgreSQL.
$schema->createTable('authserver.twofactor_attempts', function ($table) { /* … */ });
// The table is the work; the hypertable is an extra.
$schema->whenCapable(DatabaseCapabilities::TIMESCALEDB, function () use ($schema) {
HypertableRegistry::apply($schema, 'authserver.twofactor_attempts');
});
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 = (int) $db->getInsertId();
}
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) { ... });