krowinski/php-mysql-replication
A PHP library for reading MySQL binary logs via the replication protocol. Stream binlog events such as inserts, updates, deletes, and schema changes to build CDC pipelines, audit trails, or cache invalidation without polling. Includes typed event classes and a simple consumer API.
Install and prerequisites
composer require krowinski/php-mysql-replication
The package reads the MySQL binary log directly over the replication protocol, so before any PHP code matters, the server must be configured for it:
log_bin, binlog_format = ROW — statement/mixed formats will not produce the row events this library consumes).REPLICATION SLAVE and REPLICATION CLIENT privileges.slave_id per consumer process. Two processes sharing an ID will fight over the stream.Where to look first
MySQLReplication\Config\ConfigBuilder — the fluent config entry point. Everything you tune (connection, slave_id, binlog file/position, event filters) starts here.MySQLReplication\MySQLReplicationFactory — the run loop.MySQLReplication\Event\EventSubscriber — the base class you extend to react to inserts/updates/deletes.examples/ directory in the repo — working end-to-end scripts.Minimal first use case
Sync changes from one table into a secondary store (search index, cache, analytics table, webhook payload):
$config = (new ConfigBuilder())
->withUser('repl')
->withHost('127.0.0.1')
->withPassword('secret')
->withPort(3306)
->withSlaveId(101)
->withBinLogFileName('mysql-bin.000001')
->withBinLogPosition(4)
->withTablesOnly(['orders']) // keep the loop focused
->build();
$replication = new MySQLReplicationFactory($config);
$replication->registerSubscriber(new OrderSubscriber());
$replication->run();
class OrderSubscriber extends EventSubscriber
{
public function onUpdate(UpdateRowsEvent $event): void
{
foreach ($event->getEventInfo()->getRows() as $row) {
dispatch(new ReindexOrderJob($row['after']['id']));
}
}
}
That is the entire shape of a working consumer: config → subscriber → run.
Subscriber-per-concern, not one giant listener
Extend EventSubserver once per domain concern and register multiple subscribers on the same factory. Each override (onInsert, onUpdate, onDelete, onTableMap, allEvents) is an independent hook, so an audit subscriber and a reindex subscriber can share one replication stream instead of paying for two connections and two slave IDs.
Filter at the source, not in PHP
v11.0 gave you withDatabasesOnly/withTablesOnly; v11.1.0 adds the inverse — databasesIgnore and tablesIgnore. Use the ignore list for the noisy tables you never want (sessions, queue internals, migration bookkeeping) and the allow list for the handful you do. This is the single highest-leverage config decision: row events for tables you discard still cost parse time and memory, and the release notes call out the throughput work in the same breath as the filtering options for a reason.
Lean on useTableMapMetadata for correct column types
Set useTableMapMetadata(true) to have TableMapBuilder derive column metadata from the TABLE_MAP_EVENT itself rather than inferring it. This is what makes types outside the scalar set — JSON, enums, temporal and binary columns — come through as the right PHP values. Enable it whenever you store payloads or compare values, and let TableMapBuilder be the seam you test against if you need custom type mapping.
Checkpoint the binlog position
The run loop is long-lived and will eventually restart (deploy, OOM, DB failover). Persist binlog filename + position (and the GTID set if you use GTIDs) after each processed batch — a replication_checkpoints table or a cache key is sufficient — and feed it back into ConfigBuilder on boot. Without this, every restart replays from your configured starting position and either double-dispatches jobs or silently skips windows, depending on where you start.
Laravel integration shape
ReplicationManager wrapper) in a service provider so config lives in config/replication.php and reads from env().php artisan replication:consume) under Supervisor/systemd — not inside a queue worker, Octane worker, or HTTP request.Backpressure and batching
For high-churn tables, collect row changes and flush every N rows / T seconds via allEvents, then update the checkpoint once per flush. This reduces round-trips to your downstream store and keeps checkpoint writes proportional to batches rather than rows.
binlog_format must be ROW. This is the most common first failure. Verify with SHOW VARIABLES LIKE 'binlog_format'; a STATEMENT server will simply never give you the row events your subscribers expect.
Duplicate slave_id produces silent, confusing breakage — dropped events or a stream that stalls after a reconnect. Allocate IDs deliberately (config, not a random value), and make them unique per environment.
Filters are evaluated at table-map time, not at row time. With tablesIgnore/databasesIgnore, the decision is made when the TABLE_MAP_EVENT arrives. DDL, renames, and tables created after startup can behave differently than you expect — if a table is newly added to your allow list, restart the consumer rather than assuming live pickup.
Checkpoints must survive DDL. A schema change mid-stream can leave your stored position pointing at an event whose column layout no longer matches. Store the checkpoint after the batch commits downstream, and keep a manual "rewind to position X" path for recovery.
Long-running processes leak by nature. Periodically recycle the process (Supervisor does this for you), and watch memory on tables with wide rows. The v11.1.0 throughput improvements help, but they do not change the fact that a stream consumer is a daemon — treat it like one, with health checks on replication lag.
Use the new debug config dump. v11.1.0 adds a config dump for exactly this purpose: when a subscriber fires for the wrong tables or not at all, dump the resolved config first. Nine times out of ten the answer is a filter or a wrong slave ID, not a parsing bug.
Column metadata depends on your path. If you have not enabled useTableMapMetadata, values from JSON/binary/temporal columns may arrive as raw strings. Enable it and validate the shape of a few representative rows against the source table before shipping downstream logic.
Do not parse the after/before arrays positionally. Key them by column name from the table map; positional access breaks the moment someone adds a column, and it will break in production at 2 a.m.
GTIDs vs file/position. Pick one. If the server uses GTIDs, configure GTID and let the library track the set; mixing GTID starts with hard-coded file positions is the classic source of "it replayed three days of events after redeploy."
Test with the Docker setup. The repo's integration tests (expanded in v11.1.0 for ignore filters and metadata parsing) are the fastest way to reproduce a filtering or metadata bug without touching a staging primary.
How can I help you explore Laravel packages today?