nih/legacy-gateway

Route-level legacy fallback for route-by-route migration in PSR-15 applications built on nih/http-kernel.

Maintainers

Package info

github.com/nih-soft/legacy-gateway

Documentation

pkg:composer/nih/legacy-gateway

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.1 2026-04-11 11:31 UTC

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:

  • LegacyGatewayBootstrap inserts LegacyGatewayMiddleware immediately after RouteMatchMiddleware
  • LegacyGatewayMiddleware decides 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:

  • RouteMatchMiddleware runs first and decides whether the current request matches a modern route
  • LegacyGatewayMiddleware runs 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:

  1. The request enters the nih/http-kernel application.
  2. Routing runs and produces a RouteMatchResult.
  3. If the route was matched, the request continues through the normal PSR-15 pipeline.
  4. If routing returns RouteMatcher::NOT_FOUND, the middleware hands off to the legacy entrypoint.
  5. 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/login are matched by the modern router and handled by PSR-15 middleware and handlers
  • /catalog, /checkout, and all unmatched URLs fall through to legacy/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:

  • RouteMatchMiddleware must run before LegacyGatewayMiddleware
  • 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 entrypoint argument of type string|Closure
  • throws for an empty string
  • requires a RouteMatchResult request attribute
  • only attempts handoff when routing produced RouteMatcher::NOT_FOUND
  • falls through to the next handler when a string entrypoint is 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.