The Hidden Failure Modes of Laravel + PgBouncer + PlanetScale
Originally posted on Medium.
Read the originalProduction-Ready Laravel & Postgres: Scaling, Failover, and Connection Resilience
PlanetScale Postgres offers a managed, branch-native database layer for your Postgres nodes. This enables branch-based deployments, global replication, and connection multiplexing. However, it introduces failure modes not found in standard Laravel and Postgres environments. This article outlines how to address three key production issues:
- **PgBouncer binding incompatibilities:**Boolean values and prepared statements behave differently when routed through a connection pooler.
- **Replica lag and read/write routing:**Retrying a failed read on the same replica that encountered an error is usually ineffective.
- PlanetScale-specific lost connection signals: Laravel’s default detector does not recognize several of these errors.
The following code has been tested in production on a heavy OLTP workload.
Architecture Overview
Laravel app
│
├── write connection ──► PgBouncer ──► PlanetScale primary branch
│
└── read connection ──► PgBouncer ──► PlanetScale replica
PlanetScale places PgBouncer in front of each branch. Your Laravel application connects to PgBouncer endpoints rather than directly to Postgres. By default, PgBouncer uses transaction pooling mode on PlanetScale, which means:
- Server-side prepared statements are not supported because each transaction receives a different backend connection.
- Emulated prepared statements (PDO-level) must be used instead.
- Type binding semantics change because PHP’s true and false values cannot be reliably passed as PDO::PARAM_BOOL. The pooler may route the bind call and the execute call to different backend connections.
Part 1: Fixing Boolean and Type Bindings for PgBouncer
Laravel’s default PostgresConnection uses PDO::PARAM_BOOL when binding PHP booleans. With PgBouncer transaction pooling, this is inconsistent because the backend receiving the bind may not execute the query.
To resolve this, convert booleans to strings at the PHP layer before they reach PDO, and bind them as PDO::PARAM_STR.
/**
* Prepare the bindings for the query.
*
* Casts booleans to strings ('true'/'false') for PgBouncer compatibility.
*/
public function prepareBindings(array $bindings)
{
$grammar = $this->getQueryGrammar();
foreach ($bindings as $key => $value) {
if ($value instanceof DateTimeInterface) {
$bindings[$key] = $value->format($grammar->getDateFormat());
} elseif (is_bool($value)) {
$bindings[$key] = $value ? 'true' : 'false'; // value;
} elseif ($value instanceof \UnitEnum) {
$bindings[$key] = $value->name;
} elseif (is_object($value) && method_exists($value, '__toString')) {
$bindings[$key] = (string) $value;
} elseif (is_object($value)) {
$bindings[$key] = json_encode($value);
}
}
return $bindings;
}
prepareBindings runs before bindValues. By the time values reach bindValues, each boolean is already the string ‘true’ or ‘false’, so the match expression defaults to PDO::PARAM_STR:
public function bindValues($statement, $bindings)
{
foreach ($bindings as $key => $value) {
$statement->bindValue(
is_string($key) ? $key : $key + 1,
$value,
match (true) {
is_int($value) => PDO::PARAM_INT,
is_resource($value) => PDO::PARAM_LOB,
$value === null => PDO::PARAM_NULL,
default => PDO::PARAM_STR, // booleans land here as strings
},
);
}
}
A few other binding edge cases are handled above:
- Arrays are JSON-encoded, which Postgres accepts for jsonb columns and array casts in Eloquent.
- BackedEnum and UnitEnum values are unwrapped to their scalar forms to avoid errors related to object-to-string conversion.
- Objects that implement __toString are cast accordingly. Objects without this method are JSON-encoded as a fallback.
Enabling Emulated Prepared Statements
In config/database.php, set PDO::ATTR_EMULATE_PREPARES on the PlanetScale connection:
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'options' => [
PDO::ATTR_EMULATE_PREPARES => true,
],
],
This configuration shifts prepared statement handling to PHP and PDO instead of the Postgres server. This is required for PgBouncer transaction pooling.
Part 2: PlanetScale-Specific Lost Connection Detection
Laravel includes LostConnectionDetector, which handles a set of known Postgres and MySQL error strings. PlanetScale’s proxy layer introduces additional failure messages that Laravel does not recognize by default:
- no primary available for branch — PlanetScale primary failover in progress
- no replica available for branch — Replica restarting or unhealthy
- no running members available for branch — All nodes for branch are unavailable
- failed to connect to upstream — PgBouncer can't reach the Postgres backend
- failed to send startup message — Startup handshake failure during reconnect
- failed to read startup message — Same, on the read side
- canceling statement due to conflict with recovery — Replica recovery conflict (hot standby)
The last message is especially important for replica workloads. It is a standard Postgres error that occurs when a read query conflicts with WAL replay on a hot standby replica, which is common during high write throughput.
Extending LostConnectionDetector:
namespace App\Database;
use Illuminate\Database\LostConnectionDetector as BaseLostConnectionDetector;
use Illuminate\Support\Str;
use Throwable;
class LostConnectionDetector extends BaseLostConnectionDetector
{
public function causedByLostConnection(Throwable $e): bool
{
if (parent::causedByLostConnection($e)) {
return true;
}
$message = $e->getMessage();
return Str::contains($message, [
'no primary available for branch',
'no replica available for branch',
'no running members available for branch',
'failed to connect to upstream',
'failed to send startup message',
'failed to read startup message',
'canceling statement due to conflict with recovery',
]);
}
}
Register this in a service provider:
use App\Database\LostConnectionDetector;
use Illuminate\Database\Connection;
public function boot(): void
{
Connection::resolveDetectorUsing(fn () => new LostConnectionDetector());
}
Part 3: Smarter Retry Logic for Replica Failures
Laravel’s default retry behavior on a lost connection is straightforward: reconnect and rerun the query on the same connection type. For a read that fails due to a conflict with recovery, this results in retrying on the same replica, which will likely fail again because the replica is still recovering.
The correct behavior is to fall back to the primary for that retry:
protected function tryAgainIfCausedByLostConnection(
QueryException $e,
$query,
$bindings,
Closure $callback
) {
if (! $this->causedByLostConnection($e->getPrevious())) {
throw $e;
}
$this->reconnect();
// If this was a read query and we're in a request context,
// retry on the primary instead of the replica.
if (
$this->latestReadWriteTypeUsed() !== 'read'
|| ! $this->shouldForcePrimaryFallbackForLostReadRetry()
) {
return $this->runQueryCallback($query, $bindings, $callback);
}
$readOnWriteConnection = $this->readOnWriteConnection;
$this->useWriteConnectionWhenReading(true);
try {
return $this->runQueryCallback($query, $bindings, $callback);
} finally {
$this->useWriteConnectionWhenReading($readOnWriteConnection);
}
}
The finally block is essential because it restores the original readOnWriteConnection flag, regardless of whether the retry throws an exception. Without this, a failed retry would route all subsequent reads through the primary for the remainder of the request.
Why shouldForcePrimaryFallbackForLostReadRetry()?
protected function shouldForcePrimaryFallbackForLostReadRetry(): bool
{
return request() !== null;
}
This guard is necessary because request() returns null in non-HTTP contexts such as queue workers, console commands, and scheduled jobs. In these cases, the request lifecycle does not manage connection state in the same way, and primary fallback may not be needed. Queue workers often use dedicated connection configurations.
For HTTP requests, this returns true, enabling the primary fallback on read retries.
Part 4: Wiring It Together
The custom connection class extends PostgresEnhancedConnection from tpetry/laravel-postgresql-enhanced, which adds Postgres-native features such as RETURNING, array operations, and full-text search helpers. All overrides integrate cleanly:
namespace App\Database;
use Closure;
use DateTimeInterface;
use Illuminate\Database\QueryException;
use PDO;
use Tpetry\PostgresqlEnhanced\PostgresEnhancedConnection;
class PostgresPGBouncerExtension extends PostgresEnhancedConnection
{
// tryAgainIfCausedByLostConnection — retry on primary for failed reads
// bindValues — PDO::PARAM_STR for booleans
// prepareBindings — stringify booleans, JSON-encode arrays/objects
}
Register the custom connection driver in AppServiceProvider:
use App\Database\PostgresPGBouncerExtension;
use Illuminate\Database\Connection;
public function register(): void
{
Connection::resolveConnectionUsing(
'pgsql',
fn ($connection, $database, $prefix, $config) =>
new PostgresPGBouncerExtension($connection, $database, $prefix, $config)
);
}
Part 5: Replica Lag and Sticky Sessions
Even with primary fallback on retry, replica lag presents a more subtle issue: a recently committed write may not be immediately visible on the replica. Laravel provides useWriteConnectionWhenReading() to force reads through the primary for the duration of a request, but you must determine when to use it.
A common approach is to track whether the current request has performed a write operation to the database:
// In a middleware or base controller
DB::afterSave(function () {
DB::connection()->useWriteConnectionWhenReading(true);
});
Alternatively, you can explicitly wrap write-then-read operations:
DB::transaction(function () use ($order) {
$order->save();
// Any reads within this closure will use the write connection automatically,
// since Laravel routes all queries inside a transaction to the primary.
});
// Back on the replica after the transaction closes.
$freshOrder = Order::find($order->id);
Inside a transaction, Laravel routes all queries to the primary. The lag issue arises after the transaction closes, when the next read returns to the replica. If you need immediately committed data in the same request, call useWriteConnectionWhenReading(true) after the transaction.
Summary
- PgBouncer breaks**PDO::PARAM_BOOL bindings** — Stringify booleans in prepareBindings, bind as PARAM_STR
- PlanetScale proxy errors not recognized as lost connections — Extend LostConnectionDetector with PlanetScale-specific strings
- Read retry after replica recovery conflict fails again — Override tryAgainIfCausedByLostConnection to use primary on first retry
- Post-write reads hit stale replica — Use useWriteConnectionWhenReading(true) after writes in latency-sensitive paths
- Queue/console context shouldn’t force primary fallback — Guard with request() !== null
The required changes are minimal: two classes and a service provider registration. Without them, PlanetScale Postgres in transaction-pooling mode will produce intermittent failures that are difficult to reproduce locally and are not visible in standard Postgres logs.

Jake Casto · Founder, Layers
Jake Casto is the founder of Layers, the enterprise search and merchandising platform built for Shopify Plus. He previously co-founded Proton, a Shopify Plus engineering studio that shipped more than 400 storefronts, where Layers began as an internal tool for a problem that kept repeating. He writes about search infrastructure, performance, and the engineering behind discovery at scale.
Connect on LinkedIn