dskripchenko/laravel-schemify
Schemify is a Laravel package that simplifies schema definition and generation with a clean, expressive API, helping you describe and manage database structures in code while keeping migrations organized and maintainable.
This package layers multiple Postgres schemas onto a single Laravel connection, so tenant/branch/"layer" data lives in separate schemas while sharing one database. Before anything else, upgrade to 3.4.2 — the two fixes below are prerequisites for a working multi-layer setup on current Laravel versions, not cosmetic patches.
Minimal path:
composer require <vendor>/<package> # 3.4.2+
php artisan vendor:publish --tag=<package>-config
Where to look first:
layers:migrate iterates. Read this before touching commands; most "it migrated the wrong schema" reports trace back to it.migrate*, db:seed and db:wipe commands to add --layer. Skim those classes to see exactly which core commands accept a layer and how they resolve the default when --layer is omitted.SET search_path on the connection. Everything runtime-facing flows through it.First use case — provision one layer end to end:
php artisan migrate:install --layer=acme # creates the per-layer migrations table
php artisan migrate --layer=acme
php artisan db:seed --layer=acme
Then confirm you actually landed where you think:
DB::selectOne('select current_schema()'); // => acme
Once one layer works, php artisan layers:migrate runs migrations across every configured layer — that's the command that should be in your deploy script.
Per-command layer selection. For one-off work, keep it explicit: --layer= on migrate, migrate:fresh, migrate:status, migrate:reset, db:seed, db:wipe. Explicit is better than relying on a default, because the default is connection-config driven and easy to misread in a long shell session.
Bulk operations via layers:migrate. This is the deployment path. Run it after the base migrate (which owns the shared/public schema) and before traffic flips. Because it loops layers in order, the first layer is your canary — if layer one fails, you know the change itself is broken rather than the loop.
Runtime switching. Switch once per request/job at a boundary, not mid-controller:
// middleware or job handle()
$layers->use($request->user()->layer);
try {
// all queries, Schema:: calls, and model access here
} finally {
$layers->reset(); // or switch back to the default
}
Pair this with a queued job that carries the layer name in its payload and switches inside handle() — serialising the layer is far more reliable than inferring it from an authenticated user at execution time.
Testing. Use a dedicated test layer rather than the default schema, and reset it in setUp(). If you use RefreshDatabase, make sure the switch happens before the trait boots, or the trait will install a migrations table in the wrong schema. A cheap guard is asserting current_schema() in your base test case.
Seeding and fixtures. Seeders are layer-aware by virtue of --layer, so per-layer reference data (roles, statuses, settings) should live in seeders that are idempotent — you will re-run them on every new layer.
Deploy sequence. migrate → layers:migrate → db:seed --layer=… for new layers only → clear caches. Keep the layer loop out of migrate itself so failures are attributable.
Command options must be declared in configure(). On Laravel 13.21+, the framework only calls getOptions() for commands without a $signature, and 13.21 moved migrate:fresh, migrate:install, migrate:status, migrate:reset, db:seed and db:wipe to signatures. Before 3.4.2 this silently deleted --layer from those overrides: the command still resolved to the package class, but passing --layer aborted the run. If you subclass any of these commands yourself, add options in configure() — never getOptions(). If you're pinned below 3.4.2 and see The "--layer" option does not exist, you're hitting this, not a config problem.
Stale schema introspection on Laravel 11 and 12. Since 3.4.0 a layer switch reuses the connection and only issues SET search_path. Queries follow the session path and are fine, but the Postgres schema builder on 11/12 reads the schema from the connection config, which still holds the previous layer. So Schema::hasTable('migrations') answers about the wrong layer: migrate:install sees the previous layer's table, skips creating one, and the following migrate dies on a missing table — layers:migrate broke from the second layer onwards. Laravel 13 asks current_schema() and was never affected. On 3.4.2 the config is kept in sync; on older 3.4.x, force a reconnect (purge the connection) after switching before any Schema:: call.
search_path is connection state, not application state. Long-lived workers (Queues, Octane) reuse connections across jobs. Always switch at the start of a job and never assume the previous job left the connection clean; a missing switch silently reads another layer's data.
Every layer needs its own migrations table. migrate:install --layer=x is not optional for layers created outside the normal flow. If a layer was created while the 3.4.0/3.4.1 bug was live, its table may be missing — delete nothing, just re-run migrate:install and reconcile.
Postgres only. The whole mechanism is SET search_path plus the pg schema builder. There is no fallback for MySQL/SQLite, and tests on SQLite will not exercise the layer logic at all.
Debugging checklist. select current_schema() for the live truth; the connection config's schema for what Schema:: believes; migrate:status --layer=x to see which migrations a layer thinks it has. When those three disagree, you have a stale-connection problem, not a migration problem.
Extension points. The command overrides are the intended seam — subclass them, add options in configure(), and delegate to the parent. Keep layer resolution behind the package's switch API rather than calling DB::statement('SET search_path…') yourself, so future releases that change how the connection is managed (as 3.4.0 did) don't strand your code.
How can I help you explore Laravel packages today?