thecodingmachine/dbal-fluid-schema-builder
A fluent, object-oriented PHP API for building Doctrine DBAL database schemas. Define tables, columns, indexes, and foreign keys with readable chained methods, making schema creation and migrations cleaner, more expressive, and less error-prone in any PHP project.
This is not a Laravel package — there is no service provider, no config file, no facade, no publishable assets. It is a framework-agnostic wrapper around doctrine/dbal's SchemaManager that gives you a chained, readable way to express DDL. That framing should drive your whole evaluation of it.
composer require thecodingmachine/dbal-fluid-schema-builder
The first thing to check after install is what DBAL major it pulled. The package's last release is January 2024, so it targets DBAL 3.x. Laravel 11 dropped its own doctrine/dbal dependency (and getDoctrineConnection() with it), so on L11+ you must wire a connection yourself and pin doctrine/dbal:^3.6 in composer.json to avoid a DBAL 4 upgrade silently breaking the package.
Where to look first, in order:
README.md — short, shows the fluent shape.src/FluidSchema.php, src/FluidTable.php, src/FluidColumn.php — the actual API surface.tests/ — this is the real documentation; the tests show every column type, index, and FK permutation and what SQL each produces.Wiring a connection from Laravel config (do this lazily in a command/migration, never at boot):
use Doctrine\DBAL\DriverManager;
use TheCodingMachine\FluidSchema\FluidSchema;
function fluidSchema(): FluidSchema
{
$cfg = config('database.connections.'.config('database.default'));
$params = [
'driver' => 'pdo_mysql', // pdo_pgsql, pdo_sqlite
'host' => $cfg['host'],
'port' => $cfg['port'],
'dbname' => $cfg['database'], // note: dbname, not database
'user' => $cfg['username'], // note: user, not username
'password' => $cfg['password'],
'charset' => $cfg['charset'] ?? 'utf8mb4',
];
return new FluidSchema(DriverManager::getConnection($params));
}
For SQLite (:memory: in tests) use ['driver' => 'pdo_sqlite', 'path' => $cfg['database']].
First use case: write one migration that creates a table with a foreign key and two indexes, then run php artisan migrate and inspect the generated SQL. If the generated names and types match what your Eloquent models expect, the package is viable for you; if not, stop here and keep Schema::create.
public function up(): void
{
fluidSchema()->createTable('invoices')
->column('id')->integer()->autoIncrement()->primaryKey()
->column('customer_id')->integer()->notNull()
->column('number')->string(32)->notNull()
->column('total_cents')->bigInteger()->notNull()->default(0)
->column('notes')->text()->nullable()
->column('issued_at')->datetime()->notNull()
->index(['customer_id'])
->uniqueIndex(['number'])
->foreignKey('customer_id')->references('id')->on('customers')->onDelete('cascade')
->save();
}
(The fluent objects buffer the changes; the terminal call — save()/commit() depending on release — flushes them to the SchemaManager. Confirm the exact terminal method in FluidSchema.php before committing; the rest of the method names follow the column(...)->type()->modifier() shape.)
Additive alter migrations. This is where the fluid syntax is genuinely nicer than raw DDL and safer than Laravel's ->change(). Fetch an existing table and append to it:
public function up(): void
{
fluidSchema()->getTable('invoices')
->column('paid_at')->datetime()->nullable()
->column('stripe_id')->string(64)->nullable()
->index(['stripe_id'])
->save();
}
Idempotent "ensure schema" scripts. Because you hold a real DBAL connection, you get SchemaManager introspection for free, which is the pattern that justifies the dependency in a Laravel app — e.g. a multi-tenant provisioning command that must be re-runnable:
$sm = $dbal->createSchemaManager();
if (! $sm->tablesExist(['tenant_settings'])) {
$schema->createTable('tenant_settings')->/* ... */->save();
}
Do the same with $sm->introspectTable('invoices')->hasColumn('paid_at') for column-level guards. Laravel's Schema::hasColumn() is a string check against a cached listing; DBAL's introspection is richer and gives you index/foreign-key metadata too.
Shared schema definitions across frameworks. The real reason this package exists: if the same schema is created by a Symfony app (Doctrine) and a Laravel app, put the fluent definitions in a shared composer package and call them from both sides. In a pure-Laravel shop this is dead weight.
Domain macros via subclassing. There is no Schema::macro() here — it's plain PHP. Subclass FluidSchema/FluidTable to add your house conventions:
class AppFluidSchema extends FluidSchema
{
// add timestamps(), softDeletes(), money('total') helpers
}
Keep them thin wrappers over column()->datetime()->nullable() etc., and unit-test the generated SQL rather than the object graph.
Testing. Run the whole thing against pdo_sqlite with :memory: in Pest/PHPUnit. Assert on $sm->introspectTable('invoices')->getColumns() rather than on SQL strings — SQLite's ALTER TABLE rewrite path means string assertions are brittle while structural assertions are stable.
In migrations, keep down() honest. The builder is forward-only. Write the dropTable('invoices') / dropColumn calls by hand; don't rely on an auto-reverse.
.env-driven table prefixes, Schema:: macros, and IDE completion on one side and a bare DBAL connection on the other.DB_PREFIX is applied by Laravel's grammar, not by DBAL. If you use prefixes, prefix manually or the migration will target unprefixed tables.FK_5A8A6C8B... / UNIQ_..., not Laravel's invoices_number_unique. Anything that greps for constraint names (ops runbooks, raw DROP FOREIGN KEY statements, dump-diff tooling) will break. Pass explicit names to index()/foreignKey().SHOW CREATE TABLE after the first run.timestamps() doesn't exist. You add created_at/updated_at as nullable datetimes yourself. Eloquent will still populate them, but useCurrent() has no equivalent — use ->default('CURRENT_TIMESTAMP') only if you've verified the platform accepts it, otherwise leave nullable.->default(null) is not the same as ->nullable(); use ->nullable() and a null column default will follow.->change() implementation) also requires DBAL, resolve the constraint before writing migrations, not after.save() partially executes, you're on your own. For multi-table changes on MySQL, wrap in an idempotent command with introspection guards rather than a transaction.Doctrine\DBAL\Logging\Middleware) or call $sm->getCreateTableSQL() on a temp table to see exactly what will run before you run it. This is the single fastest way to diagnose "why is my column the wrong type."FluidSchema/DBAL connection in a service provider — Octane workers and long-lived queue processes will hold stale handles. Build it inside the migration or command method.DB::table(...) for backfills; mixing seeding into a fluid migration makes rollbacks untestable.ALTER TABLE by recreating the table on older SQLite versions. Structural assertions pass, data-preservation assertions may not — test those against a real MySQL/Postgres container in CI.How can I help you explore Laravel packages today?