kibatic/ha-bundle
A Symfony bundle from Kibatic that brings high availability to Doctrine connections. It transparently fails over to a standby database server when the primary goes down, keeping your application online with minimal configuration and no changes to your existing queries.
First, the framework reality check. kibatic/ha-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no config/database.php integration point. If you are on Laravel, the only thing you can realistically take from it is the pattern — read the connection wrapper source, understand how it distinguishes a "primary is unreachable" exception from a generic query error, and reimplement that small piece against Laravel's DatabaseManager. Everything below describes day-to-day use on the stack it actually targets (Symfony + Doctrine DBAL), with notes on the Laravel equivalent.
Registration (Symfony).
composer require kibatic/ha-bundle — then immediately inspect composer.json. The version constraints on symfony/framework-bundle, doctrine/dbal, and php are the single most important thing you will read. A bundle that pins DBAL ^2.x will not install on a modern Symfony app at all, and that decision happens before you write a line of config.config/bundles.php (Symfony 4+) or AppKernel::registerBundles() (Symfony 2/3).doctrine.dbal.connections.<name>. The bundle works by swapping Doctrine's wrapper_class (or equivalent connection factory) for its own wrapper, so your existing connection config — host, dbname, user, password, charset — stays intact. Expect a shape like:doctrine:
dbal:
connections:
default:
wrapper_class: Kibatic\HABundle\...\Connection
host: '%primary_host%'
# bundle-specific block: replicas/slaves, health-check timeout,
# retry count, retry backoff, fallback-to-replica flag
Where to look first in the source (in this order):
connect(), __construct, and any catch around DriverException.First use case to validate. Provision a scratch MySQL primary + one replica (docker-compose is fine). Run a read query, confirm it hits the replica. Then docker stop the primary and re-run. What you want to observe: reads still succeed from the replica, writes throw a specific, catchable exception class rather than a raw driver error. If writes hang for 30 seconds on a TCP timeout before failing, you have found your first thing to tune.
Reactive failover around the first query of the request. The bundle's value is concentrated in a very short window: the moment a request opens a connection and discovers the primary is gone. Pattern it explicitly — wrap your read path so a driver exception triggers one reconnect attempt against the replica, and cache the resulting "primary is down" verdict in APCu or Redis with a short TTL (10–30s). Without that cache, every PHP-FPM worker independently rediscovers the outage, and the primary's recovery is greeted by a reconnect stampede.
Read/write split as a load lever, not an HA mechanism. If the realistic goal is absorbing read-heavy traffic (feeds, reporting, search-adjacent queries), don't put this on the failover path at all — use doctrine.dbal.connections.default.replica (DBAL 3 does this natively) or Laravel's read/write keys in config/database.php. Static splitting is predictable; dynamic failover is the part that needs tests.
Circuit breaker above the connection. Have the wrapper surface a distinguishable exception (e.g. a ReadOnlyException or a custom HAConnectionException) and catch it in a middleware / exception handler. Map it to a degraded-mode response: a banner on read pages, a 503 with a clear message on the write endpoints (checkout, signup, profile edits). This is the pattern that makes the bundle worth anything — the failover itself is a few dozen lines; the policy around it is where you'll spend the design time.
Pin CLI and migration commands to the primary. doctrine:migrations:migrate, doctrine:schema:update, and queue workers doing writes must never be routed to a replica. Add an explicit override in the console environment — a separate connections.default_cli with replicas disabled, or a Kernel::CONSOLE guard. This is the single most common way application-level failover silently corrupts a schema.
Laravel-equivalent pattern (what you'd actually build). Extend Illuminate\Database\MySqlConnection (or wrap it), override reconnect() and run() so a QueryException whose driver code matches "unknown server host" / "connection refused" (MySQL 2002/2003, SQLSTATE HY000 with the right message) flips a per-connection degraded flag, and the read path retries against the replica host. Register via a custom connector in a service provider. Scope it: it's ~200 lines and a handful of tests, and you own it. That is the honest trade against adopting an unmaintained dependency — the TPM assessment is right that this is the cheaper path.
Observability hook. Subclass the bundle's wrapper and log every routing decision (primary / replica / failover-attempt / degraded) plus a counter metric on failover events. Without this you cannot tell an outage from a slow query, and the bundle by default will degrade silently.
Testing. Integration-test the three scenarios that matter: primary down (reads succeed, writes fail cleanly), primary recovered (traffic returns, no connection storm), replica lagging (you must not serve stale data to a read-after-write flow). Do this in CI with a real MySQL replica, not a mock — the bug class lives in driver exception shapes.
The version matrix will reject you before the code does. Symfony bundles of this vintage typically pin doctrine/dbal to ^2.x and break on DBAL 3, which is where PrimaryReadReplicaConnection replaced the old master/slave model. Check composer.json first; if it doesn't install cleanly, stop — do not start patching constraints to force it.
wrapper_class is an exclusive seam. Replacing the connection wrapper means any wrapper you already had (query logging, multi-tenancy, read/write hints) is gone. You either subclass the bundle's wrapper and chain your behaviour back in, or you don't use the bundle. There is no composition point offered.
Transactions do not survive failover. If a transaction is open when the primary disappears, the bundle cannot resume it — you get a rollback mid-request. Any code that assumes "the write either happened or the request failed before it started" is now wrong. Wrap multi-step writes so a connection failure leaves the database in a state your retry logic can reason about, and never blindly retry on the app side.
Replica lag + read-after-write is the silent-corruption path. A user saves a record, the confirmation page reads from a replica that hasn't caught up, and they see their edit missing — or worse, resubmit it. Guard by routing reads immediately after a write to the primary for the duration of the request (a "sticky primary" flag), and don't rely on the bundle to know this.
Health checks on the request path cost latency. If the config exposes a poll interval, set it generously and rely on reactive exception handling for the actual detection. A synchronous health-check per request is a per-request timeout you've just added to your p99.
Persistent PDO connections (PHP-FPM) hide outages. A worker with a live primary connection won't notice the primary is gone until the socket dies, and the failover decision may be made against stale state. If you use PDO::ATTR_PERSISTENT, test the failure window explicitly.
Two sources of truth if the infrastructure also fails over. If the database sits behind RDS Multi-AZ, a proxy, or Patroni, this bundle competes with it — the app may believe the primary is down while the orchestrator is already promoting a replica. Split-brain is the failure mode here. Do not run application-level failover on top of infrastructure-level failover.
Config quirks to verify in the extension class: whether replicas are a single host or a list, whether retry/backoff is configurable per connection or globally, and what the default fallback behaviour is when both primary and replica are down (fail closed with a clear exception, or a generic driver error you can't distinguish). Also check whether config is validated at container compile time — a typo'd key that silently defaults is a nasty surprise during an incident.
Debug in dev. Set the logging level for the wrapper's namespace to debug in dev and warning in prod; you want to see routing decisions locally and only incidents in production. If the bundle doesn't log, subclass and add it — this is the cheapest extension point available.
Treat it as vendored code. With zero dependents and no upstream, fork it into your repo (repositories with a VCS entry, or copy the one class you actually need). Pin the commit. Write a test suite around the failover path before it touches production, and record an expiry date for the whole arrangement against your managed-HA migration.
How can I help you explore Laravel packages today?