macpaw/postgres-schema-bundle
Symfony bundle for PostgreSQL schema-based multi-tenancy. Switch the active database schema per request or tenant, integrate with Doctrine, and isolate data without managing separate databases. Includes schema-aware connection and listener support.
First, calibrate expectations: this is a Symfony bundle, not a Laravel package. macpaw/postgres-schema-bundle ships a Bundle class and Symfony DI configuration, so composer require macpaw/postgres-schema-bundle will install the code but nothing will boot in a Laravel app — there is no service provider, no facade, no config/postgres-schema.php. For a Laravel developer the realistic value is reference implementation and concept, not drop-in dependency. With 0 stars and 0 dependents, treat it as a readable example of schema-per-tenant Postgres, not as a package you build a product on.
The v2.0.8 patch (fix: reset schema on transaction rollback, PR #23) is the most informative thing to happen to this package in a while: it confirms that the exact failure mode you'd worry about — a leaked search_path surviving a rolled-back transaction — was live in every release up to and including v2.0.7. Read that PR's diff before anything else; it is a short, concrete illustration of the one bug that matters in this problem space, and it tells you what the bundle's own authors consider the correct reset semantics.
Minimal steps:
composer.json first — it tells you the PHP constraint, whether it pulls Doctrine DBAL/ORM, Symfony version bounds, and therefore how much of it is portable. If it depends on doctrine/dbal only, the core logic is likely just SQL + connection handling and is easy to lift.DependencyInjection/Configuration.php (or equivalent) to see the actual config surface — connection names, schema naming strategy, default schema. That file is the best summary of what the authors intended.First use case to evaluate: schema-per-tenant Postgres (one CREATE SCHEMA tenant_42 per customer, shared public for reference data). If you need that, read their schema-creation and search_path code — plus the new rollback-reset path — then reimplement in a Laravel middleware + a custom connection config. That's an afternoon of work and gives you ownership.
Connection-scoped search_path. The pattern that matters most. In Laravel, don't mutate config('database.connections.pgsql') at runtime for anything long-lived — build a separate connection entry:
config(['database.connections.tenant' => array_merge(
config('database.connections.pgsql'),
['search_path' => $schema, 'schema' => $schema],
)]);
DB::purge('tenant');
Then use DB::connection('tenant') explicitly. Explicit connection names beat global state; you avoid the class of bug where a queued job runs against whichever schema the last request happened to leave behind.
Transaction-local SET LOCAL, plus an explicit reset on the failure path. For request-scoped work, prefer:
DB::transaction(function () use ($schema) {
DB::statement("SET LOCAL search_path TO \"{$schema}\", public");
// ... queries
});
SET LOCAL reverts on commit/rollback, so it cannot leak into a reused session. This is the single most important habit if you're on PgBouncer in transaction pooling mode. The v2.0.8 fix is precisely about the rollback half of that guarantee — which is a useful reminder that "the database reverts it for me" is only true if the reset is actually scoped correctly. If you also issue session-level SET search_path anywhere (provisioning, long-running workers, migrations), pair it with an explicit reset in a finally / catch block rather than trusting the rollback alone:
try {
DB::statement("SET search_path TO \"{$schema}\", public");
// ...
} finally {
DB::statement('RESET search_path');
}
Migration repository per schema. Laravel's migrator writes to a single migrations table. For multi-schema you either (a) give each schema its own repository by overriding $connection->getMigrationTable(), or (b) run migrations once and only vary data schemas. Copy whichever approach the bundle takes — look for how it handles DDL ordering and whether it wraps schema creation in a transaction (Postgres DDL is transactional, so a failed migration can cleanly roll back the CREATE SCHEMA).
Schema lifecycle as Artisan commands. Wrap CREATE SCHEMA IF NOT EXISTS / DROP SCHEMA ... CASCADE in dedicated commands with a --dry-run. Provisioning needs to be idempotent and auditable; ad-hoc SQL in Tinker is how tenants get half-created.
Keep shared data in public. Reference tables (countries, plans, permissions) stay in public and get resolved by search_path fallback. This is the main reason schema-per-tenant scales better than database-per-tenant: cross-schema joins still work, and one connection pool serves everyone.
search_path is session state — and the rollback path is where packages get it wrong. Under persistent connections or a transaction-mode pooler, a set-but-not-reset search_path will serve tenant A's data to tenant B. Use SET LOCAL inside a transaction, or DISCARD ALL on release. This is the one bug that turns a multi-tenant app into an incident report, and this package shipped it until v2.0.8: v2.0.7 and earlier did not reset the schema when a transaction rolled back. Pin ^2.0.8 if you take the dependency at all, and if you port the code, port the fix too.CREATE on public is no longer granted to PUBLIC. Your migration role needs explicit GRANT CREATE ON SCHEMA public or schema creation will fail in a way that looks like a permissions mystery.DROP SCHEMA will fail or orphan objects. Standardise on one migration role and use SET ROLE deliberately.DependencyInjection/ and Resources/config/ folders, and vendor the remaining SQL/DBAL helpers.^2.0.8 floor, and write your own integration test that provisions a schema, migrates it, writes, reads, then deliberately throws inside a transaction and asserts the next query lands back in public. That rollback assertion is the test the package itself was missing until now.SELECT current_schema(), current_setting('search_path') in the same connection before the failing query. Nine times out of ten the answer is "the connection was reused and nobody reset it" — and if the previous request rolled back, pre-2.0.8 code guaranteed exactly that.How can I help you explore Laravel packages today?