Search by

2lovelaces / readscale

lidonation

Scale Laravel reads across replicas with consistency checks and bounded routing overhead

v1.0.0 2026-10-21 16:29 UTC

This package is not auto-updated.

Last update: 2026-10-22 11:22:06 UTC


README

Scale Laravel reads across replicas. Keep a request-start consistency fence. Fall back before user SQL when a reader is stale or unavailable.

Release candidate, not yet on Packagist. Source is staged privately. Do not enable on production traffic until adopter acceptance is complete.

Support

  • PHP 8.3, 8.4, 8.5; Laravel 12 and 13; PostgreSQL 17 and 18.
  • PostgreSQL PDO is required for the configured routing connection. MySQL, MariaDB, SQLite and SQL Server are explicitly rejected when routing is enabled. Unrelated application connections are untouched.
  • Direct PostgreSQL readers or session-bound poolers only. Transaction and statement pooling are unsupported.
  • Default off and primary-only. Enable only explicitly audited read-only GET route names.
  • Standard isolated HTTP workers. Nested/overlapping scopes and excluded nested routes fall back. Replacing the named connection inside a scope cannot reuse its reader. Concurrent coroutines sharing one Laravel application/container are unsupported; use separate worker applications.

Readscale adds a primary connection/probe per eligible request and a replica-validation round trip before each routed query. It is a read-capacity/offload feature, not a promise that individual queries run faster.

Install after publication

composer require 2lovelaces/readscale:^1.0
php artisan vendor:publish --tag=read-routing-config

Before publication, use a private Composer VCS repository and an explicitly reviewed commit. Do not pretend a development branch is a stable release.

Laravel discovers TwoLovelaces\Readscale\ReadRoutingServiceProvider. In config/read-routing.php:

return [
    'enabled' => env('READSCALE_ENABLED', false),
    'primary_only' => env('READSCALE_PRIMARY_ONLY', true),
    'connection' => 'pgsql',
    'reader_mode' => 'direct',
    'reader' => [
        'host' => env('READSCALE_READER_HOST'),
        'port' => (int) env('READSCALE_READER_PORT', 5432),
        'sslmode' => 'verify-full',
        'sslrootcert' => env('READSCALE_SSL_ROOT_CERT'),
    ],
    'connect_timeout_seconds' => 1,
    'statement_timeout_ms' => 250,
    'allowed_routes' => ['articles.show'],
    'primary_route_prefixes' => ['auth.', 'wallet.', 'payments.', 'admin.', 'nova.', 'horizon.'],
];

Apply read-routing middleware to an eligible named route, after tenant/auth connection setup but before application database reads. Preserve existing application restrictions. Do not audit routes from their HTTP method or URL alone. Financial, authentication, administrative and effectful routes stay primary. Commands and jobs have no read scope and stay primary.

Route::get('/articles/{article}', ArticleController::class)
    ->name('articles.show')
    ->middleware('read-routing');

Do not configure Laravel native read hosts on the same named connection. Writer DB_URL is resolved by Laravel first. The reader derives database, credentials, timezone and search path from the effective writer configuration, replacing only explicit endpoint settings. Existing connector bindings and enhanced PostgreSQL connection classes are retained. Runtime writer session state is not copied: SET ROLE, SET search_path, custom tenant/RLS settings, temporary tables and session advisory locks do not carry over to the dedicated reader. Do not allowlist routes that depend on these settings. Configure tenant database/login/search path in the effective Laravel connection configuration before the middleware, or keep those routes primary. Reader credential overrides must preserve equivalent authorization and RLS behavior; matching cluster identity alone does not prove equivalent access.

Tenant acceptance boundary

The real PostgreSQL suite includes two restricted tenant logins, separate tenant schemas and a shared table with forced row-level security. It exercises consecutive alpha/beta/alpha requests on a fresh connection per tenant, denial of the other schema, and reader disposal after a worker exception. A session-based tenant route using SET ROLE, a custom setting and a temporary table is explicitly excluded from replica routing and runs on its existing writer.

These are synthetic package tests, not acceptance of an application's tenancy. The application must reset its own writer session state and purge/reconfigure the named connection when changing tenants; resetting Readscale does not restore writer SQL state. Reader credentials must enforce the same authorization as the writer. Keep session-dependent routes primary, and do not enable an adopter until its actual authentication, tenant and long-lived worker paths have passed equivalent checks.

Database permissions and safety

Use a restricted application login, TLS, and reachable primary and standby endpoints. Reader TLS certificate paths must not contain whitespace, quotes, backslashes, semicolons or control characters: Laravel inserts these values unquoted into the libpq connection string. Use simple absolute paths. Passwords are passed separately and may contain punctuation. A DBA must grant the application role execution of pg_control_system() and pg_control_checkpoint() as well as its existing schema/table rights. Granting these functions exposes cluster identity/control metadata; review that security tradeoff. Readscale opens its extra sessions with default read-only mode and a statement timeout, without changing the application's writer session.

At request entry Readscale captures the primary WAL insertion position and cluster identity. Before every replica read the exact reader PDO must prove matching database name, system identifier/timeline, recovery mode and replay at or beyond that fence. If a previous request committed before this request began, its commit precedes the fence. This is not serializable isolation and promises no ordering between concurrent requests. Disable readers during failover/restore and revalidate the topology before enabling again.

Transactions, query-builder locking reads and modified-record routing use Laravel's primary behavior. Stale or pre-query unavailable readers fall back to the writer once for the scope. Permission, authentication, SQL and statement-timeout errors propagate. Readscale never retries a user callback, write, request or job; native Laravel lost-connection behavior is not replaced. Writes disguised as reads are not supported.

A connect timeout bounds libpq connection attempts; PostgreSQL statement timeout bounds server execution. Neither is a universal deadline for DNS, network blackholes or kernel stalls. Use infrastructure/network timeouts for wall-clock guarantees. The test contract's 4.5-second fallback budget is for the declared isolated topology, not arbitrary production networks.

Observe and roll back

Listen to TwoLovelaces\Readscale\Events\ReadRouted using Laravel's event dispatcher. Fields are role, bounded reason and checkSeconds. Events do not contain SQL, parameters, hosts or users. Never perform slow synchronous I/O in a listener. Request-fence setup is included in the request benchmark, not the per-read event duration.

Set READSCALE_PRIMARY_ONLY=true, rebuild Laravel's config cache and restart long-lived workers to roll back to primary without uninstalling. Alternatively set READSCALE_ENABLED=false to restore the untouched native path on a fresh worker. Do not use mutable request-shared configuration as a live flag.

Reproduce the release gate

composer install
composer format:check
composer lint
PG_BIN=/usr/lib/postgresql/17/bin bash tests/integration/run-postgres.sh
php scripts/check-performance.php performance-result.json

The harness needs PostgreSQL server/client tools, Python 3 and pdo_pgsql/pdo_sqlite. It creates and removes one primary and two independent physical standbys. It uses no production database or credentials. It fails if integration coverage is skipped.

The fixed contract in performance-contract.json requires 1,000,000 rows (at least 200 MB), 1,000 paired samples per workload, 200 new request scopes, and four fallback scenarios. Each workload independently gates added-query median <= 0.5 ms, p95 <= 2 ms, p99 <= 5 ms. Request setup active time p95 <= 20 ms and p99 <= 40 ms; fallback maximum <= 4.5 seconds; at most one fence connection and one reader connection per scope.

Workloads: indexed point/range reads, joins, tenant keyset pages, exact numeric aggregates and native type boundaries. Both sides use native Laravel selects on the same reader PDO with alternating measurement order. Full results must match, including binary streams. Cold request measurement separately times the fence and reader setup, with fixture replay catch-up between them excluded and reported as replica_preparation_ms. The package itself never waits for replay in production. Warmup is excluded; database/client fetch time is included; binary normalization is outside the timed section. Fallbacks cover refused connection, stale replay, terminated backend and a listening endpoint that never completes PostgreSQL handshake. This is not a primary-saturation/load-throughput benchmark.

Native fixtures include numeric limits up to 131,072 integral/16,383 fractional digits, integer/money extrema, date/timestamp extrema, special float values, Unicode/NULL/JSON, bytea, arrays/ranges, network, geometry and XML. This samples built-in type/value boundaries; it does not allocate every PostgreSQL maximum-sized 1 GB field or test every extension type. The bulk table has 1,000,000 varied synthetic records. Fixtures are UTF-8 and deterministic.

CI runs required Linux/amd64 correctness, native ARM64 correctness and ARM64 performance matrices, each across all 12 supported PHP/Laravel/PostgreSQL combinations. Correctness runs on the shared runner and publishes JUnit without waiting for performance. Benchmarks require the dedicated readscale-mac-arm64 runner tag, are serialized across this project, and begin only after correctness and release tooling pass. The reserved Mac lane is configured separately from this source; see CI lane requirements. Pending benchmark jobs block release and promotion. Any missing metric, workload, sample, real PostgreSQL driver, row count, fallback result, or exceeded budget blocks the archive. No auto-retry turns a regression green. Each harness invocation clears stale output before setup. Performance JSON exists only when measurements reach their summary; resource snapshots retain failure exit status and mark unavailable counters as unknown.

Launch

See the launch checklist. Public source publication, Packagist submission, a signed-off tag, documentation DNS/TLS, preview canary and production activation are separate gates. A green benchmark alone does not approve deployment to live applications.