Skip to content

marko/database-readwrite

Read/write connection splitting with replica routing for Marko database connections. Wraps any existing marko/database driver connection — all write operations and transactions route to the primary, all reads route to one of your configured replicas. Uses the decorator pattern: ReadWriteConnection implements ConnectionInterface and TransactionInterface so the rest of the application code is unchanged.

Terminal window
composer require marko/database-readwrite

This automatically installs marko/database (the interface package) as a dependency.

Set driver to readwrite in your config/database.php, then declare a connections block with your write primary and one or more read replicas:

config/database.php
<?php
declare(strict_types=1);
return [
'driver' => 'readwrite',
'connections' => [
'write' => [
'driver' => 'pgsql',
'host' => $_ENV['DB_WRITE_HOST'] ?? 'localhost',
'port' => (int) ($_ENV['DB_WRITE_PORT'] ?? 5432),
'database' => $_ENV['DB_DATABASE'] ?? 'marko',
'username' => $_ENV['DB_USERNAME'] ?? 'postgres',
'password' => $_ENV['DB_PASSWORD'] ?? '',
],
'read' => [
[
'driver' => 'pgsql',
'host' => $_ENV['DB_READ_HOST_1'] ?? 'replica-1',
'port' => (int) ($_ENV['DB_READ_PORT_1'] ?? 5432),
'database' => $_ENV['DB_DATABASE'] ?? 'marko',
'username' => $_ENV['DB_USERNAME'] ?? 'postgres',
'password' => $_ENV['DB_PASSWORD'] ?? '',
],
[
'driver' => 'pgsql',
'host' => $_ENV['DB_READ_HOST_2'] ?? 'replica-2',
'port' => (int) ($_ENV['DB_READ_PORT_2'] ?? 5432),
'database' => $_ENV['DB_DATABASE'] ?? 'marko',
'username' => $_ENV['DB_USERNAME'] ?? 'postgres',
'password' => $_ENV['DB_PASSWORD'] ?? '',
],
],
'read_strategy' => 'random', // 'random' (default) or 'weighted'
],
];
KeyTypeRequiredDescription
connections.writearrayYesSingle write (primary) connection config
connections.readarray[]YesOne or more replica connection configs
connections.read_strategystringNoReplica selection strategy: random (default) or weighted
read[n].weightintNoRequired when read_strategy is weighted; positive integer

Each connection config inside write and read[] follows the same structure as a standalone driver config (e.g., marko/database-pgsql).

Selects a replica uniformly at random on each read query. Use when all replicas have similar capacity:

config/database.php
'connections' => [
// ...
'read_strategy' => 'random',
],

Routes a proportional share of read traffic to each replica based on its weight. Use when replicas have different hardware or capacity:

config/database.php
'connections' => [
'write' => [ /* primary config */ ],
'read' => [
[
'driver' => 'pgsql',
'host' => 'replica-1',
// ...
'weight' => 3, // receives 3/4 of reads
],
[
'driver' => 'pgsql',
'host' => 'replica-2',
// ...
'weight' => 1, // receives 1/4 of reads
],
],
'read_strategy' => 'weighted',
],

Weights are positive integers. The probability of selecting a replica equals its weight divided by the total of all weights. Each weight must be a positive integer or it is rejected at config parse time.

After any write operation or transaction, subsequent reads within the same request are routed to the write connection rather than a replica. This prevents stale reads caused by replication lag.

The sticky flag is set by:

  • execute() — any INSERT, UPDATE, DELETE, or DDL statement
  • beginTransaction() — entering a transaction
  • transaction(callable $callback) — the entire callback runs on the primary; the sticky flag is cleared automatically when the callback completes
use Marko\Database\Connection\ConnectionInterface;
class OrderService
{
public function __construct(
private ConnectionInterface $connection,
) {}
public function placeOrder(array $data): int
{
// Writes set the sticky flag
$orderId = $this->connection->execute(
'INSERT INTO orders (user_id, total) VALUES (?, ?)',
[$data['user_id'], $data['total']],
);
// This query routes to the write connection (sticky), not a replica,
// so it sees the order that was just inserted
return $this->connection->query(
'SELECT id FROM orders WHERE id = ?',
[$orderId],
)[0]['id'];
}
}

The sticky flag persists until resetStickyState() is called. In a PHP-FPM application this happens automatically because each request runs in a fresh process. In long-running processes you must call it manually (see Long-Running Processes).

prepare() always routes to the write connection. Prepared statements are typically used for bulk writes or repeated mutation patterns, so routing them to the primary is the safe default. There are no production callers of prepare() in the current Marko core, so this policy has no performance impact on stock setups.

When a read query fails on a replica with a PDOException or MarkoException, ReadWriteConnection removes that replica from the candidate pool and retries the query on the next available replica. This continues until:

  • A replica responds successfully, or
  • All replicas are exhausted, at which point ReadException is thrown
ReadException: All replicas failed to execute the query: <replica-1 message>; <replica-2 message>
Context: While attempting to route a read query to an available replica
Suggestion: Check that at least one replica is reachable and accepting connections

Fallback is per-request only. The replica pool is rebuilt fresh on the next request (PHP-FPM) or the next call to resetStickyState() (long-running processes).

In PHP-FPM the sticky flag is cleared automatically at the end of each request because each request is a new process. In a queue worker or other long-running process the sticky flag persists for the lifetime of the process. Call resetStickyState() between jobs to restore replica routing:

use Marko\Database\ReadWrite\Connection\ReadWriteConnection;
use Marko\Database\Connection\ConnectionInterface;
class JobWorker
{
public function __construct(
private ConnectionInterface $connection,
) {}
public function run(Job $job): void
{
try {
$job->handle();
} finally {
// Always reset between jobs — even if the job threw
if ($this->connection instanceof ReadWriteConnection) {
$this->connection->resetStickyState();
}
}
}
}

ReadWriteConnection binds ConnectionInterface as a container instance. This is the same interface binding used by all other database drivers, so only one driver can be active at a time. You cannot, for example, have a PostgreSQL read/write split alongside a MySQL connection in the same container.

This constraint is inherited from the single-binding design of ConnectionInterface in marko/database and applies equally to marko/database-pgsql and marko/database-mysql.

Override ReadWriteConnection using the Preferences pattern. Define a Preference in your module’s module.php to substitute your own implementation wherever ReadWriteConnection is resolved:

module.php
<?php
declare(strict_types=1);
use Marko\Database\ReadWrite\Connection\ReadWriteConnection;
use Acme\Database\ReadWrite\Connection\CustomReadWriteConnection;
return [
'preferences' => [
ReadWriteConnection::class => CustomReadWriteConnection::class,
],
];

Your CustomReadWriteConnection must extend ReadWriteConnection (or independently implement ConnectionInterface and TransactionInterface).

Implements ConnectionInterface and TransactionInterface. Routes reads to replicas and writes to the primary.

MethodRoutes ToDescription
query(string $sql, array $bindings = []): arrayRead (replica or write if sticky)Execute a SELECT and return all rows
execute(string $sql, array $bindings = []): intWrite (sets sticky)Execute a write statement; returns affected row count
prepare(string $sql): StatementInterfaceWrite (always)Prepare a statement for repeated execution
lastInsertId(): intWriteGet the last auto-increment ID
connect(): voidWriteEstablish the write connection
disconnect(): voidWriteClose the write connection
isConnected(): boolWriteCheck if the write connection is open
beginTransaction(): voidWrite (sets sticky)Start a transaction on the write connection
commit(): voidWriteCommit the current transaction
rollback(): voidWriteRoll back the current transaction
inTransaction(): boolWriteCheck if a transaction is active
transaction(callable $callback): mixedWrite (sets sticky temporarily)Run a callback inside an auto-managed transaction; sticky flag is set for the callback duration and cleared on completion
driverName(): stringWrite (delegates)Return the write connection’s driver name (e.g. 'mysql', 'pgsql')
resetStickyState(): voidClear the sticky flag; subsequent reads route to replicas again

Thrown when all replicas fail during a single read query.

FactoryDescription
ReadException::allReplicasFailed(array $messages): selfBuilds the exception with a semicolon-joined summary of each replica’s error message

Parses and validates the connections block from config/database.php.

FactoryThrowsDescription
ReadWriteConnectionConfig::fromArray(array $config): selfReadWriteConfigExceptionValidates presence of write, non-empty read[], and valid read_strategy

Selects a replica uniformly at random. Default strategy.

Selects a replica proportionally to its configured weight. Constructed with an array of integer weights parallel to the replica array.