nih / legacy-gateway
Route-level legacy fallback for route-by-route migration in PSR-15 applications built on nih/http-kernel.
Requires
- php: 8.4 - 8.5
- nih/container: ^0.1.3
- nih/http-kernel: ^0.1.0
- nih/middleware-dispatcher: ^0.2.0
- nih/router: ^0.2.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- phpunit/phpunit: ^13.1
This package is auto-updated.
Last update: 2026-04-11 15:59:16 UTC
README
Route-level legacy facade for PSR-15 applications built on top of nih/http-kernel.
What It Is
nih/legacy-gateway lets a PSR-15 application act as a facade over an existing legacy application.
Migrating a legacy system that already works in production is almost always painful:
- the old code must keep serving real traffic
- the new code must go live incrementally
- old and new behavior must coexist for a while
- a big-bang rewrite is usually too risky
That is why route-by-route migration exists as a practical strategy.
Instead of replacing the whole legacy application at once, you move one route, one endpoint group, or one use case at a time into the new PSR-15 application. The rest of the traffic continues to be handled by legacy code until you are ready to migrate it too.
This gives teams a controlled migration boundary:
- new routes can be implemented in modern middleware and handlers
- existing legacy pages can continue to work unchanged
- the application can stay online while the boundary moves gradually toward the new codebase
The router defines the boundary between modern and legacy code:
- if a request matches a modern route, it stays in the PSR-15 pipeline
- if no route matches, control falls through to a legacy entrypoint
In other words, the PSR-15 application becomes a route-level facade in front of legacy behavior. Every newly migrated route is claimed by the modern router, and everything else still falls through to the legacy entrypoint.
This makes gradual migration possible without introducing a separate reverse proxy, splitting the application into two public entrypoints, or forcing a rewrite-first migration plan.
How Request Routing Works
The package keeps the integration surface intentionally small:
LegacyGatewayBootstrapinsertsLegacyGatewayMiddlewareimmediately afterRouteMatchMiddlewareLegacyGatewayMiddlewaredecides whether the request stays in the PSR-15 application or falls through to legacy code
The key bootstrap line is:
$app->pipeline->append(LegacyGatewayMiddleware::class, after: RouteMatchMiddleware::class);
This is the whole routing boundary in one place:
RouteMatchMiddlewareruns first and decides whether the current request matches a modern routeLegacyGatewayMiddlewareruns immediately after that decision- if a route was matched, the request stays in the modern PSR-15 pipeline
- if no route was matched, the request can fall through to legacy
Without this exact placement, the package would not be route-driven. It would either run too early, before routing knows anything, or too late, after the request had already moved deeper into the modern dispatch flow.
Request flow:
- The request enters the
nih/http-kernelapplication. - Routing runs and produces a
RouteMatchResult. - If the route was matched, the request continues through the normal PSR-15 pipeline.
- If routing returns
RouteMatcher::NOT_FOUND, the middleware hands off to the legacy entrypoint. - The handoff is wrapped in
DeferredCallableResponse, so the kernel can restore runtime state before executing legacy code.
In practice, this means the PSR-15 app can behave like a route-level proxy or facade over legacy behavior while keeping routing as the decision point.
When To Use It
Typical use cases:
- migrate a legacy system route by route
- move new endpoints such as
/api/*,/health, or/admin/*into PSR-15 code first - keep a single application entrypoint while modern and legacy code coexist
Example migration shape:
/api/users,/health, and/admin/loginare matched by the modern router and handled by PSR-15 middleware and handlers/catalog,/checkout, and all unmatched URLs fall through tolegacy/index.php
Quick Start
Applications opt in explicitly by adding LegacyGatewayBootstrap to the bootstrap list and overriding the demo entrypoint binding in an app-specific bootstrap.
Recommended bootstrap order:
return [ NIH\HttpKernel\Bootstrap\Psr17Bootstrap::class, NIH\HttpKernel\Bootstrap\ErrorHandlingBootstrap::class, NIH\HttpKernel\Bootstrap\RoutingBootstrap::class, NIH\LegacyGateway\LegacyGatewayBootstrap::class, App\Bootstrap\AppBootstrap::class, ];
Why this order matters:
RouteMatchMiddlewaremust run beforeLegacyGatewayMiddleware- the legacy gateway must see the routing result before route dispatch
- the app-specific bootstrap should override the demo fallback configured by
LegacyGatewayBootstrap
In other words, LegacyGatewayBootstrap does not just "register one more middleware". It places the legacy gateway exactly at the point where the application already knows whether the request belongs to the new router or should fall through to the old system.
LegacyGatewayBootstrap ships with a demonstration fallback only:
$app->services->auto(LegacyGatewayMiddleware::class) ->argument('entrypoint', static function (): void { echo 'Legacy Fallback'; });
Real applications are expected to replace that with their own legacy entrypoint.
Configure the Legacy Entrypoint
File-based handoff
<?php declare(strict_types=1); namespace App\Bootstrap; use NIH\HttpKernel\Bootstrap\BootstrapInterface; use NIH\HttpKernel\HttpApplication; use NIH\LegacyGateway\LegacyGatewayMiddleware; final class AppBootstrap implements BootstrapInterface { public static function boot(HttpApplication $app): void { $app->services->auto(LegacyGatewayMiddleware::class) ->argument('entrypoint', dirname(__DIR__) . '/legacy/index.php'); } }
Closure-based handoff
$app->services->auto(LegacyGatewayMiddleware::class) ->argument('entrypoint', static function (): void { require __DIR__ . '/../legacy/index.php'; });
Use a closure when the handoff needs extra bootstrapping logic. Use a file path when a direct require is enough.
Runtime Contract
LegacyGatewayMiddleware:
- requires an
entrypointargument of typestring|Closure - throws for an empty string
- requires a
RouteMatchResultrequest attribute - only attempts handoff when routing produced
RouteMatcher::NOT_FOUND - falls through to the next handler when a string
entrypointis not a readable file - calls
PipelineControl::bypassOuter()when available - returns
DeferredCallableResponse - does not emit output directly
Out Of Scope
This package stays at the routing boundary. It is not responsible for:
- output-buffer cleanup
- HTTP state restoration
- error rendering
- fatal handling
Those runtime concerns belong to nih/http-kernel, not to this package.