Configuring Laravel + PgBouncer Without Breaking Booleans, Prepared Statements, or Your Sanity
You fixed the connection pooling. Then your booleans stopped being booleans. This is the untangling.
Good evening. I understand your booleans have become integers.
You did everything right. Your Laravel application was hitting PostgreSQL connection limits — perhaps FATAL: too many connections for role "myapp" at 3am, perhaps a slow cascade of timeouts during a traffic spike — so you deployed PgBouncer in transaction mode. Connection pooling is working. Your database server is no longer drowning in idle connections. Victory.
Then a user reported that filtering by "active" accounts returned nothing. A queue worker started processing the same job twice. A feature flag check that should return false started throwing exceptions. A soft-deleted record reappeared when combined with a boolean scope.
The trail leads to one line in your config/database.php:
PDO::ATTR_EMULATE_PREPARES => true
That line is there because PgBouncer requires it. And that line is what is breaking your booleans. I have seen this exact sequence of events unfold in production more times than I should like to admit — typically on a Friday, typically affecting the one query that looked too simple to test.
This is the story of why it happens, what else it breaks, how to fix it properly, and how to prevent it from ever happening again. If you will permit me, I shall be thorough. This particular problem deserves thoroughness, because the surface-level fix (change true to 'true' in one query) conceals a deeper architectural issue that will continue producing issues until it is addressed at the root.
The boolean bug: what happens and why
PostgreSQL has a proper boolean type. It accepts true, false, 't', 'f', 'yes', 'no', '1', '0' — as strings. What it does not accept is the bare integer 1 or 0 in a boolean context. PostgreSQL is strict about types. It will not implicitly cast an integer to a boolean. This is by design. PostgreSQL's type system is one of its great strengths — it catches mistakes that MySQL would silently absorb.
PHP's PDO, when emulating prepared statements, converts PHP's true and false to the integers 1 and 0 before interpolating them into the SQL string. This is fine for MySQL, which treats 1 and true as interchangeable (MySQL's boolean is simply TINYINT(1) wearing a costume). It is not fine for PostgreSQL.
# Your Laravel model has a boolean column:
# Schema: users table, is_active BOOLEAN NOT NULL DEFAULT true
# You write a perfectly normal Eloquent query:
$activeUsers = User::where('is_active', true)->get();
# With a direct PostgreSQL connection, Laravel sends:
# SELECT * FROM "users" WHERE "is_active" = true
# ✓ Works perfectly.
# With PgBouncer in transaction mode + PDO::ATTR_EMULATE_PREPARES = true:
# SELECT * FROM "users" WHERE "is_active" = 1
# ✗ PostgreSQL error: operator does not exist: boolean = integer
#
# Or worse — it silently returns wrong results if your column
# is an integer that happens to store 0/1 instead of true/false. The error message — operator does not exist: boolean = integer — is clear enough once you see it. The difficulty is that it does not always surface immediately. Some queries silently succeed because the column happens to be nullable and the integer comparison returns an empty set rather than an error. Some queries fail only with specific Eloquent methods. Some queries work in development — where you are connecting directly to PostgreSQL without PgBouncer — and fail only in production or staging where PgBouncer sits in the path.
I have seen this bug take down production twice in environments where the application developers had no idea PgBouncer was even in the stack. The infrastructure team added it behind a load balancer. The application team discovered the change when booleans stopped working and the error logs filled with type mismatch complaints. This is a coordination problem as much as a technical one.
The most insidious variant is not the error — it is the silent wrong result. If a developer has defined a column as INTEGER DEFAULT 0 instead of BOOLEAN DEFAULT false (a common MySQL-to-PostgreSQL migration pattern), the query WHERE status = 1 succeeds but may return unexpected rows. The bug hides. It surfaces later, in a different context, and the debugging trail is cold.
PHP, PDO, and the history of boolean confusion
To understand why PDO casts booleans to integers, it helps to understand PHP's own relationship with the concept of "true."
// PHP's relationship with booleans is... permissive.
var_dump(true == 1); // true — PHP considers these equal
var_dump(true === 1); // false — strict comparison knows better
var_dump((int) true); // 1
var_dump((int) false); // 0
var_dump((string) true); // "1"
var_dump((string) false); // ""
// PDO uses (int) casting internally for PARAM_BOOL.
// This is correct for MySQL, where TINYINT(1) IS the boolean type.
// It is incorrect for PostgreSQL, which has a real BOOLEAN type
// that does not accept bare integers.
// The PDO PostgreSQL driver could override this behaviour,
// but it does not. PDO was designed in an era where MySQL
// was the default database, and boolean-as-integer was the norm.
// PostgreSQL support inherited MySQL's assumptions. PHP was born in a world where databases meant MySQL. MySQL treats booleans as integers. PHP treats booleans as integers. PDO, designed to abstract across databases, inherited these assumptions. When you bind a boolean parameter through PDO, it converts true to 1 and false to 0 via integer casting. This is not a bug in PDO — it is a design decision that prioritised MySQL compatibility.
When PDO operates in native (server-side) prepared statement mode, the type information travels through the PostgreSQL wire protocol. PostgreSQL receives a typed parameter, knows it is a boolean, and handles the comparison correctly. The integer casting happens in PHP, but PostgreSQL never sees it because the wire protocol carries the type separately from the value.
When PDO operates in emulated mode, there is no wire protocol parameter binding. PDO must serialize the PHP value into a SQL string literal and paste it into the query text. It writes 1 where it should write true. PostgreSQL receives the complete SQL string, parses it, finds an integer where a boolean is expected, and refuses the comparison.
I should note that this is not a uniquely Laravel problem. Any PHP application using PDO with emulated prepares against PostgreSQL will encounter the same bug. Symfony's Doctrine DBAL, CakePHP, Yii — they all face this. Laravel gets the most attention because Laravel is the most common PHP framework deployed with PgBouncer, and because Eloquent's expressive where('is_active', true) syntax makes boolean parameters the natural, idiomatic choice.
Why PgBouncer forces emulated prepares in the first place
This is not PgBouncer being difficult. It is a genuine architectural incompatibility between transaction-mode pooling and server-side prepared statements.
# Why does PgBouncer require emulated prepares?
#
# PgBouncer in transaction mode shuffles backend connections
# between transactions. Server-side prepared statements are
# bound to a specific PostgreSQL backend process.
#
# Client A prepares "stmt_1" on backend X:
Client A --> PgBouncer [conn 1] --> PostgreSQL [backend X]
PARSE "stmt_1" = "SELECT * FROM users WHERE id = $1"
# Backend X knows about stmt_1. ✓
# Transaction ends. PgBouncer returns backend X to the pool.
# Client A starts a new transaction. Gets backend Y this time:
Client A --> PgBouncer [conn 1] --> PostgreSQL [backend Y]
EXECUTE "stmt_1"
# Backend Y has no idea what stmt_1 is. ✗
# ERROR: prepared statement "stmt_1" does not exist
# The standard Laravel fix: enable emulated prepares.
# PDO handles parameter binding client-side, never sends PARSE/EXECUTE.
# No server-side prepared statements = no conflict.
# But PDO's client-side binding has... opinions about booleans. Server-side prepared statements live on a specific PostgreSQL backend process. They are part of that process's session state — stored in its local memory, tied to its lifetime. PgBouncer in transaction mode reassigns backend processes between transactions. The prepared statement your connection cached three seconds ago may now live on a backend that another client is using — or that has been recycled entirely.
When Client A says "execute stmt_1" and PgBouncer has assigned it to Backend Y (which has never heard of stmt_1), PostgreSQL returns an error. This is correct behaviour from PostgreSQL's perspective. It is not going to execute a prepared statement it has never parsed. And it is correct behaviour from PgBouncer's perspective — PgBouncer is a connection multiplexer, not a PostgreSQL protocol emulator. It shuffles connections; it does not replicate state.
The standard fix for Laravel is to enable PDO::ATTR_EMULATE_PREPARES. This tells PDO to handle parameter binding client-side by interpolating values directly into the SQL string, then sending the complete query as plain text. No PARSE. No BIND. No EXECUTE referencing a named statement. No server-side state to lose.
// config/database.php — the typical PgBouncer configuration
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'pgbouncer'), // PgBouncer, not Postgres
'port' => env('DB_PORT', '6432'),
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'myapp'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
'sslmode' => 'prefer',
'options' => [
PDO::ATTR_EMULATE_PREPARES => true, // ← required for PgBouncer
],
],
// With emulated prepares enabled, PDO binds parameters client-side
// by interpolating values into the SQL string before sending it.
//
// For strings and integers, this is fine.
// For booleans, PDO converts true/false to 1/0.
// PostgreSQL does not implicitly cast integer to boolean.
// Your query fails. This works for the pooling problem. But PDO's client-side interpolation has its own set of opinions about how PHP types should become SQL literals. And its opinion about booleans — cast them to integers — is wrong for PostgreSQL.
You have traded one error for another. The prepared statement error was immediate and loud. The boolean error is variable and sometimes silent. I am not certain this is an improvement.
Seeing the cast in action
To be absolutely precise about what is happening under the hood:
// What PDO does to your boolean values:
$stmt = $pdo->prepare("SELECT * FROM users WHERE is_active = ?");
$stmt->bindValue(1, true, PDO::PARAM_BOOL);
// With ATTR_EMULATE_PREPARES = false (native prepares):
// PDO sends: PARSE + BIND with a boolean parameter
// PostgreSQL receives: WHERE is_active = true ✓
// With ATTR_EMULATE_PREPARES = true (emulated prepares):
// PDO interpolates the value into the SQL string.
// PHP's true becomes the integer 1.
// PDO sends: "SELECT * FROM users WHERE is_active = 1"
// PostgreSQL receives: WHERE is_active = 1
// PostgreSQL says: ERROR — cannot compare boolean to integer ✗
// The same happens with false → 0:
User::where('is_verified', false)->count();
// Native: WHERE is_verified = false ✓
// Emulated: WHERE is_verified = 0 ✗ With native (server-side) prepared statements, the boolean value is sent as a typed parameter through the PostgreSQL wire protocol. PostgreSQL receives it as a boolean and all is well. The query planner knows the column type, knows the parameter type, and proceeds without complaint.
With emulated prepared statements, PDO must convert the PHP value to a SQL literal string and paste it into the query. PHP's true becomes the integer 1 (because PHP itself treats true === 1 as truthy). PDO does not know it is talking to PostgreSQL. It does not know the target column is a boolean. It does not consult the database schema. It just does what PHP does: true is 1.
The fix seems obvious: just cast booleans to the string 'true' or 'false'. And indeed, that is exactly what the fix packages do. But the devil is in making sure every boolean binding in your application — including those inside Eloquent, inside third-party packages, inside Laravel's own internal queries — gets the correct treatment. A fix that covers 95% of your boolean bindings is a fix that leaves 5% of them broken in production.
Debugging the boolean bug when it arrives unannounced
The first symptom is rarely a clear error message. More often, it is a feature that stops working: a filter returns no results, a dashboard shows wrong counts, a permission check denies access it should grant. The error may be caught by Laravel's exception handler, or it may be swallowed by a try/catch somewhere in your middleware.
Here is how to confirm the diagnosis and trace the problem to its source.
// Step 1: Enable query logging to see what PDO actually sends
DB::enableQueryLog();
User::where('is_active', true)->get();
User::where('is_verified', false)->count();
$queries = DB::getQueryLog();
foreach ($queries as $q) {
// $q['query'] → the SQL template
// $q['bindings'] → the bound parameters (before PDO processes them)
// $q['time'] → execution time in ms
dump($q);
}
// With emulated prepares, the 'query' will already have
// parameters interpolated. You will see:
// "SELECT * FROM \"users\" WHERE \"is_active\" = 1"
// ^
// There is your problem.
// Step 2: Check PostgreSQL logs to see what actually arrives
// postgresql.conf:
// log_statement = 'all'
// log_min_duration_statement = 0
//
// Then tail the log:
// tail -f /var/log/postgresql/postgresql-16-main.log
//
// You will see the literal SQL with 1/0 instead of true/false. The query log is your first tool. If you see integers where booleans should be, you have confirmed the problem. But the query log shows you the queries your application code generates. Third-party packages may generate queries that do not appear in your log unless you enable logging before the package runs its queries.
The PostgreSQL server log is more reliable. It shows every query that reaches the database, regardless of which code generated it. Set log_statement = 'all' temporarily, reproduce the bug, and search for = 1 or = 0 in contexts where you expect = true or = false.
One technique I have found particularly effective: search your codebase for every boolean column in your schema, then search for every Eloquent query that references those columns. This gives you a complete inventory of the exposure surface. If you have 14 boolean columns and 47 queries that reference them, you know exactly where to look and exactly what to test.
The two packages that fix the boolean bug
The Laravel community has produced two approaches to patching this at the driver level, so you do not have to chase every boolean in your codebase.
// Fix 1: Laravel PgBouncer package by Obullo
// https://github.com/obullo/laravel-pgbouncer
//
// Installs as a custom database driver that patches
// PDO's boolean handling for emulated prepares.
composer require obullo/laravel-pgbouncer
// config/database.php — change driver from 'pgsql' to 'pgbouncer'
'pgsql' => [
'driver' => 'pgbouncer', // ← the only change
'host' => env('DB_HOST', 'pgbouncer'),
'port' => env('DB_PORT', '6432'),
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'myapp'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
'sslmode' => 'prefer',
// No need to set ATTR_EMULATE_PREPARES — the driver handles it.
],
// Under the hood, the driver:
// 1. Enables emulated prepares (required for PgBouncer)
// 2. Intercepts boolean bindings and converts 1/0 → 'true'/'false'
// 3. Handles other PgBouncer quirks (no DEALLOCATE, etc.) The Obullo team has published a concise solution on GitHub — the laravel-pgbouncer package provides a drop-in database driver that wraps Laravel's PostgreSQL connection. It enables emulated prepares (for PgBouncer compatibility) while intercepting boolean parameter bindings and converting them to PostgreSQL-compatible string literals. Change your driver name from pgsql to pgbouncer and the boolean bug disappears.
The package is small — a driver class, a grammar override, and a service provider. I would encourage you to read the source code before installing it. Understanding what it does is more valuable than trusting that it does it. The core mechanism is straightforward: it catches PDO::PARAM_BOOL bindings and rewrites them as string literals that PostgreSQL accepts.
If you prefer not to add a dependency — or if the package does not support your Laravel version — you can build the same fix yourself:
// Fix 2: Manual PDO boolean casting
// If you prefer not to add a dependency, you can patch
// the boolean handling yourself in a service provider.
// app/Providers/DatabaseServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Connection;
class DatabaseServiceProvider extends ServiceProvider
{
public function boot()
{
// Register a custom grammar that casts booleans correctly
Connection::resolverFor('pgsql', function ($connection, $database, $prefix, $config) {
$conn = new \Illuminate\Database\PostgresConnection(
$connection, $database, $prefix, $config
);
// Override the query grammar to handle booleans
$conn->setQueryGrammar(new \App\Database\PgBouncerGrammar());
return $conn;
});
}
}
// app/Database/PgBouncerGrammar.php
namespace App\Database;
use Illuminate\Database\Query\Grammars\PostgresGrammar;
class PgBouncerGrammar extends PostgresGrammar
{
// Override parameter compilation to cast booleans
public function parameter($value)
{
if (is_bool($value)) {
return $value ? "'t'" : "'f'";
}
return parent::parameter($value);
}
} Both approaches solve the boolean problem. Neither solves the other problems that transaction-mode PgBouncer introduces. I want to be clear about this boundary: fixing the boolean cast is necessary but not sufficient if your application depends on any other stateful PostgreSQL feature.
"Transaction mode introduces a well-known constraint with prepared statements. A prepared statement is created on a specific PostgreSQL backend process. In transaction mode, the backend assigned to the next transaction may be different — the prepared statement does not exist there, and the query fails."
— from You Don't Need Redis, Chapter 17: Sorting Out the Connection Poolers
The manual workaround (and why it fails at scale)
Before reaching for a package, many developers try the direct approach: just cast your booleans manually.
// Workaround without packages: cast booleans explicitly in queries
// Instead of:
User::where('is_active', true)->get(); // ✗ sends 1
// Use string literals:
User::where('is_active', 'true')->get(); // ✓ sends 'true'
// Or use whereRaw:
User::whereRaw('"is_active" = true')->get(); // ✓ sends literal SQL
// Or cast in the query:
User::whereRaw('"is_active" = ?::boolean', [1])->get(); // ✓ explicit cast
// These all work, but they are fragile:
// - Every developer must remember the workaround
// - Eloquent scopes, third-party packages, and Laravel's own
// soft deletes all use boolean parameters internally
// - One missed cast = one broken query in production This works for queries you write yourself. It does not work for the queries you do not write yourself, which in a Laravel application are numerous:
- Eloquent's internal queries. Soft deletes use
WHERE deleted_at IS NOT NULL(fine) but scopes on boolean columns use PHP booleans (broken). The combination of the two can produce partial failures that are remarkably confusing to debug. - Third-party packages. Spatie's permissions, Laravel Cashier, Filament, Nova — any package that queries boolean columns binds booleans the standard way. You cannot manually cast booleans inside code you do not control without forking the package.
- Your future self. Every new query, every new developer on the team, must remember to cast booleans. One missed cast is one production bug. The workaround is a cognitive tax that compounds over time.
- Laravel's own internals. The framework itself generates boolean comparisons in certain contexts — model attribute casting, migration column defaults, seed data. These are not queries you typically think about.
// Laravel's SoftDeletes trait uses whereNull / whereNotNull,
// which does NOT trigger the boolean bug:
User::withTrashed()->get();
// SELECT * FROM "users" ✓
User::onlyTrashed()->get();
// SELECT * FROM "users" WHERE "deleted_at" IS NOT NULL ✓
// But if you add a boolean scope to a soft-deleteable model:
class User extends Model
{
use SoftDeletes;
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}
// This combines both:
User::active()->get();
// SELECT * FROM "users"
// WHERE "is_active" = 1 ← broken (boolean bug)
// AND "deleted_at" IS NULL ← fine
//
// The query partially works, partially breaks.
// Soft deletes are fine. The boolean scope is not.
// This is exactly the kind of partial failure that is hardest to debug. Manual workarounds are for prototypes. For production, fix it at the driver level or fix it at the architecture level. There is no middle ground that does not eventually produce a 3am incident.
Testing for booleans: catch it before production does
Regardless of which fix you choose, I would strongly suggest adding explicit boolean tests to your test suite. The fix works today. A Laravel upgrade, a package update, a database driver change — any of these could alter the boolean binding path. A test catches the regression.
// A test that catches the boolean bug before production does.
// tests/Feature/PgBouncerBooleanTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PgBouncerBooleanTest extends TestCase
{
use RefreshDatabase;
public function test_boolean_where_clause_works()
{
User::factory()->create(['is_active' => true]);
User::factory()->create(['is_active' => false]);
// This is the query that breaks with emulated prepares
$active = User::where('is_active', true)->count();
$inactive = User::where('is_active', false)->count();
$this->assertEquals(1, $active);
$this->assertEquals(1, $inactive);
}
public function test_boolean_update_works()
{
$user = User::factory()->create(['is_active' => true]);
$user->update(['is_active' => false]);
$user->refresh();
$this->assertFalse($user->is_active);
}
public function test_boolean_in_eloquent_scope_works()
{
// If you have a scope like:
// public function scopeActive($query) {
// return $query->where('is_active', true);
// }
User::factory()->count(3)->create(['is_active' => true]);
User::factory()->count(2)->create(['is_active' => false]);
$this->assertEquals(3, User::active()->count());
}
public function test_third_party_boolean_queries_work()
{
// Test any third-party package that queries boolean columns.
// Spatie permissions, Cashier subscription status, etc.
// If they use boolean parameters, they will break.
//
// Run this test against your actual PgBouncer configuration
// in CI, not just against direct PostgreSQL connections.
$this->assertTrue(true, 'Add package-specific boolean tests here');
}
} The critical detail: run these tests against your actual PgBouncer configuration, not just against a direct PostgreSQL connection. If your CI pipeline connects directly to PostgreSQL while production routes through PgBouncer, you will pass every test and still break in production. Mirror your production database architecture in your test environment. This is non-negotiable.
If you are using Docker Compose for local development, add a PgBouncer container in the same pool mode as production. The configuration overhead is minimal. The protection it provides is considerable.
# If you are on AWS Elastic Beanstalk or ECS with RDS + PgBouncer:
# .ebextensions/pgbouncer.config or ECS task definition
# Common mistake: configuring PgBouncer at the infrastructure level
# without telling the application team. The application works in
# local development (direct Postgres), fails in staging/production
# (through PgBouncer).
# Checklist for infrastructure teams:
# 1. If you add PgBouncer, notify the application team
# 2. Document which pool_mode you are using
# 3. If transaction mode: the application MUST configure emulated prepares
# 4. If session mode: no application changes needed (but no pooling benefit)
# 5. Test boolean queries through PgBouncer before deploying
# 6. Test advisory locks if the app uses database queues
# 7. Test LISTEN/NOTIFY if the app uses database notifications
# In Docker Compose for local development,
# mirror your production PgBouncer configuration:
#
# pgbouncer:
# image: edoburu/pgbouncer:1.22
# environment:
# DATABASE_URL: postgres://myapp:password@postgres:5432/myapp
# POOL_MODE: transaction
# MAX_CLIENT_CONN: 200
# DEFAULT_POOL_SIZE: 20
# ports:
# - "6432:6432" Everything else that breaks in transaction mode
Booleans get the most attention because they cause immediate, visible errors. But transaction-mode PgBouncer breaks a longer list of PostgreSQL features that Laravel applications commonly depend on. I should like to present the complete inventory, because fixing booleans while ignoring the rest is rather like repainting a room and ignoring the water damage in the ceiling.
// Laravel features that break with PgBouncer in transaction mode,
// even after fixing booleans:
// 1. Laravel Horizon (Redis queues are fine, but database queues break)
// Queue workers hold advisory locks to prevent job overlap.
// PgBouncer reassigns the backend. The lock is orphaned.
// Another worker grabs the same job. Duplicate processing.
// 2. Database notifications (via pg_notify)
// Broadcasting events through PostgreSQL LISTEN/NOTIFY:
Schema::create('notifications', function (Blueprint $table) {
// ...
});
DB::statement('LISTEN new_notification');
// PgBouncer drops the subscription when the backend switches.
// Notifications silently disappear.
// 3. Session-level settings
// Laravel Telescope sets statement_timeout per query:
DB::statement("SET statement_timeout = '5s'");
$results = DB::select('SELECT * FROM expensive_view');
// PgBouncer may execute the SELECT on a different backend
// where statement_timeout is still the default (0 = no limit).
// Your "protected" query runs forever.
// 4. Database sessions (SESSION_DRIVER=database)
// Session locks use advisory locks. Same problem as #1. | PostgreSQL feature | PgBouncer transaction mode | PgBouncer session mode | Gold Lapel |
|---|---|---|---|
| Prepared statements | Broken — statements lost on backend reassignment | Works | Works |
| Boolean parameters (PDO emulated) | Broken — PDO casts true/false to 1/0 | N/A (native prepares) | N/A (native prepares) |
| SET session variables | Broken — reset on backend switch | Works | Works |
| Advisory locks | Broken — lock held on wrong backend | Works | Works |
| LISTEN / NOTIFY | Broken — subscription lost | Works | Works |
| search_path changes | Broken — reset between transactions | Works | Works |
| Temporary tables | Broken — dropped on backend switch | Works | Works |
| Connection pooling efficiency | High (fewer backends needed) | Low (1:1 client-to-backend) | High (intelligent session routing) |
Session-mode PgBouncer fixes all of these, but session mode assigns one PostgreSQL backend per client connection for the entire session. That eliminates the pooling efficiency that made you deploy PgBouncer in the first place. With 200 Laravel workers, you need 200 PostgreSQL backends. You are back where you started, with a proxy in the middle adding latency for no benefit.
This is the fundamental trade-off of PgBouncer: transaction mode gives you pooling but breaks stateful features. Session mode preserves stateful features but gives you no pooling. There is no configuration that provides both. This is not a criticism of PgBouncer — it is an honest description of its design constraints. PgBouncer is a connection multiplexer. It was not designed to maintain session state across backend reassignments, and expecting it to is asking for something it never promised.
The advisory lock problem deserves special attention
Of all the items in that table, advisory locks cause the most damage in Laravel applications. If you are using the database queue driver (which holds advisory locks to prevent duplicate job processing), transaction-mode PgBouncer will silently create duplicate processing. Worker A acquires a lock on Backend X. PgBouncer reassigns Worker A to Backend Y. Worker A thinks it still holds the lock. Worker B is assigned to Backend X, which no longer has an active lock holder. Worker B acquires the same lock. Both workers process the same job.
This failure mode is silent, intermittent, and data-corrupting. It does not throw errors. It does not appear in logs (unless you are specifically monitoring for duplicate job execution). It simply processes jobs twice, charges customers twice, sends emails twice, creates duplicate records. If your application uses database queues with PgBouncer in transaction mode, switch to Redis queues immediately — or fix the connection pooling layer.
PgBouncer 1.21+: prepared statements fixed, booleans too
PgBouncer 1.21, released in late 2023, added prepared statement tracking — the PgBouncer team has documented the capability in their changelog. With max_prepared_statements configured, PgBouncer intercepts PARSE and EXECUTE commands and re-prepares statements when a client gets assigned a new backend.
# PgBouncer 1.21+ adds prepared statement tracking.
# pgbouncer.ini
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200
# This tells PgBouncer to track prepared statements and
# re-prepare them when a client gets a new backend.
#
# With this setting, you can DISABLE emulated prepares:
# config/database.php
'options' => [
PDO::ATTR_EMULATE_PREPARES => false, // native prepares again!
],
# Native prepares mean:
# - Booleans work correctly (true/false, not 1/0)
# - Query plans are cached server-side (15-30% faster for repeated queries)
# - Type safety is enforced by PostgreSQL, not by PDO string interpolation
#
# Caveats:
# - Requires PgBouncer 1.21+ (check: pgbouncer --version)
# - Memory overhead: PgBouncer stores SQL text of tracked statements
# - Re-preparation latency on first execution after backend switch
# - Advisory locks, LISTEN/NOTIFY, session variables still broken This is significant for the boolean problem because it removes the need for emulated prepares entirely. With PgBouncer tracking prepared statements, you can set PDO::ATTR_EMULATE_PREPARES => false and use native server-side prepares. Native prepares send booleans as typed parameters. No casting bug. No packages needed. The fix is not in your PHP code — it is in your PgBouncer configuration.
# How to check your PgBouncer version and configuration:
# Direct check:
pgbouncer --version
# PgBouncer 1.22.0
# If PgBouncer is running as a service:
SHOW VERSION;
# via psql connected to PgBouncer's admin console:
# psql -p 6432 -U pgbouncer pgbouncer -c "SHOW VERSION;"
# Check current pool mode:
# psql -p 6432 -U pgbouncer pgbouncer -c "SHOW CONFIG;" | grep pool_mode
# Check if prepared statement tracking is enabled:
# psql -p 6432 -U pgbouncer pgbouncer -c "SHOW CONFIG;" | grep max_prepared
# On managed services, check your provider's documentation:
# - AWS RDS Proxy: NOT PgBouncer — uses its own pooler, no prepared stmt tracking
# - Supabase Supavisor: NOT PgBouncer — Elixir-based pooler
# - Neon: uses their own proxy — supports prepared statements natively
# - DigitalOcean Managed DB: PgBouncer-based, check version in dashboard
# - Render: PgBouncer included, version depends on deployment date
# - Railway: no built-in pooler — add your own Before relying on this, verify three things:
- Your PgBouncer version. Many managed PostgreSQL services and Linux distributions still ship PgBouncer versions older than 1.21. AWS RDS Proxy, Supabase Supavisor, and Neon's pooler are not PgBouncer at all — this setting does not apply to them. DigitalOcean, Render, and other managed services may ship older versions depending on when you provisioned the service. Check the actual binary version, not the documentation.
- Memory overhead. PgBouncer stores the SQL text of every tracked statement. With
max_prepared_statements=200and 50 backends, that is up to 10,000 cached statement texts. For typical web applications with 30-50 unique query shapes, this is modest — perhaps a few megabytes. For applications with large dynamic queries or heavy use of query builders, set a conservative limit and monitor withSHOW STATS. - The rest of the breakage table still applies. Advisory locks, LISTEN/NOTIFY, session variables, temporary tables — PgBouncer 1.21 does not fix any of these. Only prepared statements are tracked. If your application depends only on connection pooling and correct boolean handling, PgBouncer 1.21+ is a genuine solution. If you need anything else from the "Broken" column, this version does not change the fundamental architecture.
I should be honest about a limitation of this approach: PgBouncer's prepared statement tracking adds a layer of complexity to your pooler. PgBouncer must now parse the PostgreSQL wire protocol deeply enough to intercept PARSE and EXECUTE messages, maintain a mapping of statement names to SQL text per client, and re-prepare statements on new backends. This is more work than PgBouncer was originally designed to do. The implementation is solid — PgBouncer is mature, well-tested software — but it is a meaningful increase in the proxy's responsibilities.
The performance cost of emulated prepares
Beyond the boolean bug, there is a performance conversation that gets lost in the debugging. Emulated prepares are not just a compatibility mode — they are a measurably slower execution path. And you are paying this cost on every query, not just the ones with boolean parameters.
# Prepared statements: the performance you are leaving on the table
#
# Benchmark: 10,000 identical SELECT queries (indexed lookup, single row)
# PostgreSQL 16, Laravel 11, PHP 8.3
#
# Emulated prepares (PgBouncer workaround):
# Total: 4,200ms
# Per query: 0.42ms avg
# PostgreSQL work: parse → plan → execute (every time)
#
# Native prepared statements (direct connection or Gold Lapel):
# Total: 2,900ms
# Per query: 0.29ms avg
# PostgreSQL work: execute only (plan cached after first call)
#
# Difference: 31% faster with prepared statements
# At 1,000 queries/second, that is 130ms of server time saved per second.
# At 10,000 queries/second, 1.3 seconds of compute recovered per second.
#
# Prepared statements are not a micro-optimization.
# They are free performance you are currently paying to disable. Server-side prepared statements allow PostgreSQL to parse and plan a query once, then execute it repeatedly with different parameters. The parse and plan phases are skipped on subsequent executions. For a query that runs 10,000 times per minute — a user lookup, an inventory check, a permission query — the cumulative savings are substantial.
With emulated prepares, every execution is a fresh query. PostgreSQL parses it, plans it, optimises it, executes it, discards the plan. Next time the same query arrives with a different user ID, PostgreSQL does all of that again. It does not recognise the query as "the same one with different parameters" because it is not — it is a different SQL string with the parameter already interpolated.
# Where the time goes — a single SELECT query, broken down:
#
# Emulated prepares (every execution):
# PHP PDO interpolation: ~0.01ms (trivial)
# Network (query text): ~0.08ms (slightly larger payload)
# PostgreSQL parse: ~0.04ms (parse SQL text into AST)
# PostgreSQL plan: ~0.06ms (generate execution plan)
# PostgreSQL execute: ~0.15ms (run the plan, return rows)
# Total: ~0.34ms
#
# Native prepared statements (after first execution):
# Network (execute + params): ~0.05ms (smaller payload)
# PostgreSQL execute: ~0.15ms (reuse cached plan)
# Total: ~0.20ms
#
# The 0.14ms difference seems trivial for one query.
# A Laravel request that executes 30 queries saves 4.2ms.
# A queue worker processing 1,000 jobs per minute saves 7 seconds.
# At scale, "trivial" multiplied by volume is not trivial at all. The 30% number will vary by workload. Write-heavy applications with diverse query shapes will see less benefit from prepared statements — if every query is unique, there is nothing to cache. Read-heavy applications with a small set of hot queries will see more. CRUD-heavy Laravel applications, where the same SELECT * FROM users WHERE id = ? runs thousands of times per minute, benefit the most.
An honest counterpoint on prepared statement performance
I should note that the performance benefit of server-side prepared statements is not universally positive. PostgreSQL's prepared statement planner uses a "generic plan" after five executions of the same statement. The generic plan is optimised for the average parameter value, not the specific value you are binding right now. For columns with highly skewed data distributions — where 99% of rows have one value and 1% have another — the generic plan may be significantly worse than a custom plan generated for the specific parameter.
This is PostgreSQL's plan_cache_mode setting, and it is worth understanding. For most web application queries, the generic plan is fine or better. For analytics queries or queries against columns with extreme value skew, you may want plan_cache_mode = force_custom_plan for specific queries. This is an edge case, but it is a real one, and a waiter who ignores edge cases will eventually be reminded of them in production.
That said, the general direction is always the same: native prepared statements are faster than emulated ones for the typical Laravel workload. When you enable PDO::ATTR_EMULATE_PREPARES for PgBouncer compatibility, you are not just accepting the boolean bug. You are accepting a permanent performance penalty on every query your application executes.
A migration checklist, because details matter
Whichever path you choose — boolean fix package, PgBouncer 1.21+ native prepares, or an architectural change — here is what I would recommend checking before deploying.
# Migration checklist: PgBouncer boolean fix
#
# Before deploying:
# □ Identify your PgBouncer version (pgbouncer --version)
# □ Identify your pool_mode (transaction, session, or statement)
# □ Count boolean columns: SELECT column_name, table_name
# FROM information_schema.columns
# WHERE data_type = 'boolean' AND table_schema = 'public';
#
# If PgBouncer < 1.21 (must use emulated prepares):
# □ Install obullo/laravel-pgbouncer OR custom grammar
# □ Run boolean test suite against PgBouncer (not direct Postgres)
# □ Check third-party packages for boolean queries
# □ Test advisory lock behaviour if using database queues
#
# If PgBouncer ≥ 1.21 (can use native prepares):
# □ Set max_prepared_statements in pgbouncer.ini
# □ Set PDO::ATTR_EMULATE_PREPARES => false
# □ Remove any boolean workarounds (they are no longer needed)
# □ Benchmark: compare query times before and after
# □ Monitor PgBouncer memory usage (SHOW STATS)
#
# For either approach:
# □ Mirror PgBouncer config in local Docker Compose
# □ Add boolean tests to CI pipeline
# □ Document the PgBouncer configuration for the team
# □ Set up alerts for "operator does not exist" errors in logs The most common failure mode I observe is not technical — it is organisational. The infrastructure team adds PgBouncer without telling the application team. Or the application team adds a boolean fix package without testing against the actual PgBouncer configuration. Or the fix works for the application's own queries but breaks a third-party package that nobody tested.
Document your PgBouncer configuration alongside your application configuration. They are not separate concerns. They are two halves of the same system, and a change to either affects the other.
Gold Lapel: pooling without any of this
I have spent most of this article helping you work around PgBouncer's limitations. I should be direct about the alternative, because this is where Gold Lapel provides its strongest value proposition.
// Gold Lapel — change the connection string. That is the entire migration.
// config/database.php
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '5433'), // Gold Lapel's port
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'myapp'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
'sslmode' => 'prefer',
// No special options needed.
// No ATTR_EMULATE_PREPARES.
// No custom driver.
// No boolean patches.
],
// What you get:
// ✓ Native prepared statements — booleans are booleans
// ✓ Connection pooling — session-mode, protocol-aware
// ✓ Advisory locks work across transactions
// ✓ LISTEN/NOTIFY subscriptions persist
// ✓ SET commands stick to your session
// ✓ Temporary tables survive between queries
// ✓ No packages to install, no grammar overrides, no workarounds
//
// Gold Lapel speaks the full PostgreSQL wire protocol.
// To Laravel, it IS PostgreSQL. To PostgreSQL, it IS a client.
// The proxy is invisible. Your application code does not change. Gold Lapel is a PostgreSQL proxy that provides connection pooling as one of its capabilities — alongside query optimisation, auto-indexing, and result caching. It is not a fork of PgBouncer. It is not a wrapper around PgBouncer. It is a different architecture built from the ground up in Rust, with full awareness of the PostgreSQL wire protocol.
The key difference: Gold Lapel provides session-mode connection pooling with intelligent backend routing. Each client connection gets a stable, consistent backend session. Prepared statements persist. Session variables stick. Advisory locks work. LISTEN/NOTIFY subscriptions survive.
"But session mode means no pooling," you might say. Not the way Gold Lapel does it.
PgBouncer's session mode is a 1:1 mapping: one client connection holds one backend for the entire session. Gold Lapel's session-mode pooling is protocol-aware. It understands which connections have active state (prepared statements, locks, subscriptions, session variables) and which are clean. Clean connections can be reassigned. Stateful connections are preserved. The proxy makes routing decisions at the protocol level, not at the transaction boundary.
The result: you get the pooling efficiency of PgBouncer's transaction mode with the correctness of session mode. No boolean bugs. No advisory lock orphans. No lost subscriptions. No performance penalty from emulated prepares.
An honest boundary on the Gold Lapel alternative
I should be forthcoming: Gold Lapel is not free, and PgBouncer is. If the boolean casting bug is your only problem and you can deploy PgBouncer 1.21+ with max_prepared_statements, that is a perfectly adequate solution that costs nothing. Apply it and move on. I would be a poor waiter indeed if I suggested expensive remedies for problems that have simple, free solutions.
The case for Gold Lapel strengthens when the boolean bug is one of several PgBouncer limitations affecting your application. If you are also dealing with advisory lock orphans in your queue workers, or LISTEN/NOTIFY breakage in your event broadcasting, or session variable resets in your multi-tenant search_path configuration — then patching each issue individually becomes its own maintenance burden. Gold Lapel eliminates the entire category. Whether that elimination is worth the cost depends on how many of those issues you are actually experiencing.
For Laravel specifically, the migration is composer require goldlapel/goldlapel-laravel. The service provider auto-discovers. No custom drivers. No grammar overrides. No PDO::ATTR_EMULATE_PREPARES. Your existing queries stay exactly the same, and every issue described in this article ceases to exist.
Choosing your path
You have four options. Each is legitimate for different circumstances.
- PgBouncer + boolean fix package. The simplest change for existing deployments. Install the package, change the driver name, deploy. Your booleans are fixed. Your other PgBouncer limitations remain. This is the right choice if booleans are your only problem and you are running PgBouncer older than 1.21.
- PgBouncer 1.21+ with native prepares. The cleanest PgBouncer-based solution. Configure
max_prepared_statements, disable emulated prepares, and both the boolean bug and the performance penalty disappear. This is the right choice if you control your PgBouncer version and do not depend on advisory locks, LISTEN/NOTIFY, or session variables. - PgBouncer session mode. The nuclear option for correctness. Everything works, nothing is pooled. This is the right choice if you need full PostgreSQL compatibility and your connection count is low enough that pooling is unnecessary — perhaps a small application with a handful of workers.
- Gold Lapel. Connection pooling with full PostgreSQL compatibility. No workarounds, no limitations, no maintenance burden. This is the right choice if you need both efficient pooling and stateful PostgreSQL features, and the cost is justified by the operational simplicity.
The worst choice is no choice — leaving emulated prepares enabled, scattering manual boolean casts through your codebase, and hoping nothing else breaks. That is not engineering. That is hoping. And hoping, if you will forgive the observation, is not a strategy that performs well under load.
Your booleans deserve to be booleans. Your prepared statements deserve to be prepared. And your application deserves a database connection layer that does not require patching the ORM's type system to function correctly.
I trust you will make the appropriate selection. Should you require further assistance, the manor is always open.
Frequently asked questions
Terms referenced in this article
A brief detour, if you would indulge me. The prepared statement complications that PgBouncer introduces are part of a larger story about connection pooling and its trade-offs. I have written a detailed comparison of PgBouncer against alternatives that weighs transaction mode quirks, protocol support, and the poolers that handle prepared statements more gracefully.