Pop PHP
Database

Schema Builder

The schema builder is the query builder's counterpart for DDL: the same fluent object, rendering CREATE TABLE and ALTER TABLE in whichever dialect the connected adapter speaks. Write the table once and it stands up on MySQL, PostgreSQL and SQLite alike.

Creating a Table#

createSchema() on the adapter returns a builder bound to it, the same way createSql() does. create() names the table, the type methods add columns to it, and execute() sends the result:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$schema = $db->createSchema();
$schema->create('widgets')
    ->int('id', 16)->increment()
    ->varchar('name', 255)->notNullable()
    ->decimal('price', 10, 2)
    ->datetime('created_at')
    ->primary('id');

$schema->execute();

Each type method takes the column name first, then whatever that type needs.

The dialect differences are the point of the builder. That statement renders as `id` INT(16) AUTO_INCREMENT with an ENGINE=InnoDB suffix on MySQL, a CREATE SEQUENCE alongside the table on PostgreSQL, and "id" INTEGER PRIMARY KEY AUTOINCREMENT on SQLite.

Column Types#

The type methods cover the families every adapter has:

Family Methods
Integer integer(), int(), bigInt(), mediumInt(), smallInt(), tinyInt()
Fixed and floating point decimal(), numeric(), float(), real(), double()
String char(), varchar()
Text text(), tinyText(), mediumText(), longText()
Binary blob(), mediumBlob(), longBlob()
Date and time date(), time(), datetime(), timestamp(), year()

The integer and string methods take a size second; decimal(), numeric(), float(), real() and double() take a size and a precision. For a type outside the list, addColumn($name, $type, $size, $precision, $attributes) writes it through verbatim.

Where an adapter spells a type differently, the builder renders the one it has. SQLite renders decimal('price', 10, 2) as NUMERIC, float() as REAL and varchar('name', 255) as a plain VARCHAR; PostgreSQL renders blob() as TEXT and datetime() as TIMESTAMP. Render the schema and read it once when you are targeting more than one adapter.

Modifiers, Keys and Indexes#

increment(), notNullable(), nullable(), defaultIs() and unsigned() modify the column most recently added, which is what makes the chain read the way it does. increment() takes the starting value, 1 by default.

primary(), index() and unique() name their column again, since a key can span several — pass an array for a composite one, or no argument to fall back to the last column added:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$schema = $db->createSchema();
$schema->create('widget_tags')
    ->int('widget_id', 16)
    ->varchar('tag', 64)
    ->bigInt('hits')->unsigned()->defaultIs(0)
    ->primary(['widget_id', 'tag'])
    ->index('tag', 'idx_tag');

$schema->execute();

An index is named for the columns it covers — index_tag, or index_widget_id_tag for a composite — and the second argument names it yourself, as above.

unsigned() renders on MySQL; PostgreSQL and SQLite take the column without it. On SQLite a primary() renders as a UNIQUE constraint, and an increment() column additionally carries PRIMARY KEY AUTOINCREMENT inline; MySQL and PostgreSQL emit PRIMARY KEY in both cases.

Foreign Keys#

Foreign keys are declared on the referencing table, and references(), on() and onDelete() build one constraint between them:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$schema = $db->createSchema();
$schema->create('widget_notes')
    ->int('id', 16)->increment()
    ->int('widget_id', 16)
    ->text('note')
    ->primary('id')
    ->foreignKey('widget_id')->references('widgets')->on('id')->onDelete('CASCADE');

$schema->execute();

MySQL and PostgreSQL take the constraint as a trailing ALTER TABLE ... ADD CONSTRAINT that the same execute() applies against the table it just created. SQLite takes constraints at creation time, so the builder writes it inline in the CREATE TABLE instead — the same PHP produces whichever form the adapter wants. Declare foreign keys inside create() for a schema SQLite will run: alter() names the reason if you reach for it there, since changing a constraint on SQLite means recreating the table.

SQLite checks foreign keys per connection, and starts with the check off. Run PRAGMA foreign_keys=ON on a connection that should reject a row pointing at a parent that is not there.

Altering, Renaming and Dropping#

The other four verbs return their own objects from the same builder, and all four execute the same way:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$schema = $db->createSchema();
$schema->alter('widgets')->varchar('sku', 64)->after('name');
$schema->execute();

$schema = $db->createSchema();
$schema->alter('widgets')->dropColumn('created_at');
$schema->execute();
Call Renders
create($table) / createIfNotExists($table) CREATE TABLE, with IF NOT EXISTS on the second
alter($table) ALTER TABLE; a type method adds a column, dropColumn(), dropIndex() and dropConstraint() remove one
rename($table)->to($new) RENAME TABLE on MySQL, ALTER TABLE ... RENAME TO on PostgreSQL and SQLite
truncate($table) TRUNCATE TABLE, and DELETE FROM on SQLite, which has no TRUNCATE
drop($table) / dropIfExists($table) DROP TABLE, with IF EXISTS on the second

after() positions a newly added column and is MySQL syntax — PostgreSQL and SQLite take the column without it. cascade() on drop() or truncate() is MySQL and PostgreSQL.

modifyColumn($old, $new, $type, $size) renders MySQL's CHANGE COLUMN, which renames and retypes in one statement. On PostgreSQL and SQLite it renders RENAME COLUMN, so for a migration that runs on more than MySQL, add the new column, copy the values across and drop the old one.

Rendering and Executing#

Reading the SQL does not spend it. echo $schema; prints the statement and leaves the object intact, so the same object renders identically again and still executes afterward — which is what makes logging a migration before running it a one-liner:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$schema = $db->createSchema();
$schema->dropIfExists('widgets');
$schema->create('widgets')->int('id', 16)->increment()->primary('id');

echo $schema;

$schema->execute();

One schema object holds as many statements as you give it, and it renders them grouped by verb rather than in call order: every drop() first, then create(), alter(), rename() and truncate(). The sample above prints the DROP before the CREATE for that reason, and would do so whichever order the two calls came in. Use a separate schema object per statement when the sequence matters.

execute() runs the statements and clears the object; execute(false) runs them and keeps them, and reset() clears without running. disableForeignKeyCheck() wraps the whole render in SET foreign_key_checks = 0 on MySQL and PRAGMA foreign_keys=off on SQLite, which is what lets a batch drop tables in any order.

setEngine() and setCharset() on a create() set the MySQL table options, InnoDB and utf8 by default:

PHP
use Pop\Db\Record;

$db = Record::getDb();

$schema = $db->createSchema();

$create = $schema->create('widgets');
$create->int('id', 16)->increment()->primary('id');
$create->setEngine('MyISAM')->setCharset('utf8mb4');

$schema->execute();

See Also#

  • Migrations — wrapping a pair of these in up() and down(), and running them with Kettle
  • Query Builder — the same builder pattern applied to SELECT, INSERT, UPDATE and DELETE
  • Querying — the adapter createSchema() hangs off
  • Records & the ORM — the table classes a schema is written for
  • pop-db README — the full column-type and schema API surface