vented / laravel-plenum
Application-layer routing for Laravel. Pin requests to specific backing connections (database, Redis, custom) using consistent hashing, pluggable routing strategies, active health checks, and automatic failover.
Fund package maintenance!
Requires
- php: ^8.4
- ext-hash: *
- ext-pdo: *
- esi/consistent-hash: ^2.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/redis: ^13.0
- illuminate/support: ^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- larastan/larastan: ^3.0
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
This package is auto-updated.
Last update: 2026-05-24 21:13:18 UTC
README
Application-layer routing for Laravel. Pin requests to specific backing connections — databases, Redis nodes, or any custom resource Laravel can address by connection name — using consistent hashing, with pluggable routing-key strategies, active health monitoring, and automatic failover.
Warning
Experimental — not for production use. Laravel Plenum is at 0.1.0 and the API, configuration keys, and runtime behaviour may change without notice between point releases. Health-check semantics, event payloads, and the hashing implementation are not yet stable. Please evaluate in development or staging environments only until a 1.0 release is tagged.
What and why
When you run a pool of interchangeable backends — a multi-master Postgres cluster, a Redis deployment, an application-sharded service — you usually want a stable mapping from some logical identity (user, tenant, project, session) to a specific backend, so replication can catch up or cache locality holds.
Load-balancer sticky sessions don't solve this: the same user on two devices needs the same backend, and your load balancer doesn't know what application identity means. Plenum handles it at the application layer. On each request it computes a routing key, consistent-hashes it across the healthy backends, and sets the appropriate Laravel default connection. When a node fails its health check, traffic reshuffles around it automatically.
Requirements
- PHP 8.4+
- Laravel 13+
- A cache store (used to share health state across workers)
Installation
composer require vented/laravel-plenum
Publish the config:
php artisan vendor:publish --tag="plenum-config"
The package auto-registers its service provider and facade. No migrations or views to publish — Plenum doesn't persist anything to your database.
Quick start: database routing
Add your node pool to .env:
PLENUM_DB_NODES="db_1=db-1.internal:5432,db_2=db-2.internal:5432,db_3=db-3.internal:5432" PLENUM_DB_DRIVER=pgsql PLENUM_DB_DATABASE=appdb PLENUM_DB_USERNAME=app PLENUM_DB_PASSWORD=secret PLENUM_DB_SSLMODE=require PLENUM_STRATEGY=auth-user
That's it. Plenum will register each node as a Laravel database connection (db_1, db_2, db_3) on boot, then on every request the middleware computes a routing key from the authenticated user, hashes it across the healthy nodes, and calls DB::setDefaultConnection() for that request. All Eloquent queries, raw queries, and DB:: calls in that request go to the chosen node.
Quick start: Redis routing
PLENUM_REDIS_NODES="redis_1=redis-1.internal:6379,redis_2=redis-2.internal:6379" PLENUM_REDIS_PASSWORD= PLENUM_REDIS_DATABASE=0 PLENUM_REDIS_CLIENT=phpredis
Resolve the routed Redis connection at the call site:
Plenum::redis()->set('cache:key', 'value');
Routing strategies
A strategy answers one question: given the current request, what's the routing key? Plenum ships five built-ins:
auth-user—auth()->id()(returnsnullfor guests; wrap withcompositeif you need a session fallback)session-only— session ID alwaystenant— wraps a closure that returns your tenant identifier, useful withstancl/tenancyor similarcallback— a closure registered at boot time, with a custom name for theX-Plenum-Strategyheadercomposite— tries strategies in order until one returns a non-null key
For example, to route by authenticated user and fall back to the session for guests, bind a composite:
use Vented\Plenum\Contracts\RoutingStrategy; use Vented\Plenum\Strategies\AuthUserStrategy; use Vented\Plenum\Strategies\CompositeStrategy; use Vented\Plenum\Strategies\SessionOnlyStrategy; $this->app->singleton(RoutingStrategy::class, fn ($app) => new CompositeStrategy( $app->make(AuthUserStrategy::class), $app->make(SessionOnlyStrategy::class), ));
Set the active strategy via PLENUM_STRATEGY or by binding your own:
use Vented\Plenum\Contracts\RoutingStrategy; final class ProjectStrategy implements RoutingStrategy { public function resolve(): int|string|null { return request()->route('project')?->id; } public function name(): string { return 'project'; } } // In a service provider: $this->app->bind(RoutingStrategy::class, ProjectStrategy::class);
Strategies must never throw — return null if no key can be determined and the router will handle it.
Multiple drivers in one app
Register both pools and the same routing key drives both:
PLENUM_DB_NODES="db_1=...,db_2=...,db_3=..." PLENUM_REDIS_NODES="redis_1=...,redis_2=...,redis_3=..." PLENUM_STRATEGY=tenant
Tenant 42 will land on db_2 and redis_2 (or whichever pair the hash ring assigns) — every request, every worker, deterministically. Drivers are independent: a Redis failure in one request won't affect database routing in that same request.
Health checks and failover
The default PingHealthChecker delegates to each driver: a SELECT 1 for database nodes, PING for Redis. Results are cached briefly (10s for healthy, 30s for down by default) so you're not pinging on every request.
Plenum ships a plenum:probe command but does not schedule it for you. Wire it into Laravel's scheduler so the cached state stays fresh — e.g. in routes/console.php (Laravel 11+):
use Illuminate\Support\Facades\Schedule; Schedule::command('plenum:probe')->everyTenSeconds();
Or, in long-lived environments like supervisord, run php artisan plenum:probe --watch as a daemon.
When a node fails, Plenum dispatches NodeMarkedDown and the ring rehashes around the survivors. When it comes back, NodeRecovered fires. The Plenum::execute() helper wraps an operation with automatic retry-on-different-node behaviour and dispatches FailoverOccurred when it kicks in.
Tune via env:
PLENUM_PROBE_INTERVAL=10 PLENUM_HEALTHY_CACHE_TTL=10 PLENUM_DOWN_CACHE_TTL=30 PLENUM_PROBE_TIMEOUT=3
Custom health checker (pgEdge example)
For pgEdge users who want to factor Spock replication lag into the health decision:
use Vented\Plenum\Contracts\HealthChecker; final class SpockLagAwareHealthChecker implements HealthChecker { public function probe(string $driver, string $node, ConnectionDriver $resolver): NodeStatus { // Run SELECT lag_bytes FROM spock.lag_tracker and mark down if above threshold. } // ... other methods }
Bind it in a service provider and Plenum will use it instead of the default.
Background jobs and queue workers
Queue jobs don't carry an HTTP request, so the auth-user or session strategies have nothing to resolve. Pass the routing key into the job's constructor and route from inside handle():
final class ProcessProjectReport implements ShouldQueue { public function __construct(public readonly int $projectId) {} public function handle(): void { Plenum::execute('database', $this->projectId, function () { // All Eloquent / DB:: calls in here go to the routed node, // and a failover candidate is tried automatically if it fails. }); } }
Plenum::execute() is the recommended entry point for any work that should be retried on the next healthy node on a connection-level failure.
Debugging
Set PLENUM_EXPOSE_DEBUG_HEADER=true and every response carries X-Plenum-database, X-Plenum-redis, and X-Plenum-Strategy headers showing which node served the request and why. Three Artisan commands help operationally:
php artisan plenum:diagnose # show config, current health state, ring layout php artisan plenum:distribution # simulate distribution across N synthetic keys php artisan plenum:probe --watch # long-lived prober for supervisord
Listen to the events to wire up alerting:
Event::listen(NodeMarkedDown::class, fn ($e) => Slack::alert( "{$e->driver} node {$e->node} marked down: {$e->reason}" ));
Dashboard
Plenum ships a small read-only status page that visualises the same information as plenum:diagnose and plenum:distribution in a browser. It's mounted at /plenum and is enabled by default in local only — production and staging serve a 404 unless you opt in.
To expose it outside local, set the env var and register an auth gate:
PLENUM_DASHBOARD_ENABLED=true
// In a service provider's boot() method use Vented\Plenum\Facades\Plenum; Plenum::auth(fn ($request) => $request->user()?->can('viewPlenum') ?? false);
Without a custom gate, non-local requests return 403. The callback receives the inbound Request and must return true to allow access.
Configurable via env:
PLENUM_DASHBOARD_PATH=admin/plenum # default: plenum PLENUM_DASHBOARD_DOMAIN=admin.example.com # optional PLENUM_DASHBOARD_SAMPLES=1000 # distribution sample count
The page ships its own bundled stylesheet inline. If you want to theme it, publish the view and edit it directly:
php artisan vendor:publish --tag="plenum-views"
Operational notes
Adding or removing a node reshuffles roughly 1/N of the keys — consistent hashing's main selling point. Plan capacity for the brief surge of cache misses or replication catch-up during a node change.
If every node is unhealthy, Plenum throws NoHealthyNodesException rather than guessing — fail loudly, fail visibly, page someone.
Testing
Plenum ships test helpers:
use Vented\Plenum\Testing\FakeStrategy; use Vented\Plenum\Testing\FakeHealthChecker; $this->app->bind(RoutingStrategy::class, fn () => new FakeStrategy('test-key-42')); $this->app->bind(HealthChecker::class, FakeHealthChecker::class);
Then assert against Plenum::nodeFor() deterministically.
Run the package's own test suite:
composer test
FAQ
Does this replace HAProxy / Traefik / a load balancer? No. Your load balancer still distributes requests across web servers. Plenum routes application data access once the request is inside a Laravel worker.
Does this rewrite SQL or split reads from writes? No. v1.0 routes all reads and writes for a given key to the same node. Read/write splitting is a possible future addition.
Does it work with a single-node pool? Yes — degenerate case, but supported.
What happens if I install it but don't configure any nodes? It becomes a no-op. Installed-but-unconfigured won't crash your app.
Comparison to alternatives
| Approach | Per-user pinning | Cross-device | Survives node changes | App-aware |
|---|---|---|---|---|
| HAProxy IP-hash | ✗ (enterprise NAT) | ✗ | partially | ✗ |
| Sticky cookies | ✗ (per-device) | ✗ | ✗ | ✗ |
| HAProxy header hashing | partially | depends | partially | partially |
| Dedicated proxies (pgBouncer, etc.) | depends | depends | yes | ✗ |
| Laravel Plenum | ✓ | ✓ | ✓ (consistent hash) | ✓ |
Changelog
See CHANGELOG.
Contributing
See CONTRIBUTING.
Security Vulnerabilities
See the security policy.
Credits
License
MIT. See LICENSE.
