pecee/simple-router
A fast, lightweight PHP router with a simple, expressive API. Supports route groups, middleware, custom regex patterns, named routes and reverse routing, plus optional parameter binding and subdomain routing. Works with or without a framework.
pecee/simple-router is a standalone, Laravel-flavored router. It gives you Route::get(), groups, middleware, named routes, and controller actions — but without the framework, the container, or Eloquent. Think of it as the Illuminate\Routing component extracted and made usable in any PHP project.
Install and wire it in your front controller:
// composer require pecee/simple-router
require __DIR__ . '/vendor/autoload.php';
use Pecee\SimpleRouter\SimpleRouter as Router;
// Optional: let route strings omit FQCNs
Router::setDefaultNamespace('App\\Controllers');
Router::get('/', fn () => 'Hello')->name('home');
Router::get('/users/{id}', function ($id) {
return "User {$id}";
});
Router::get('/health', [HealthController::class, 'index']);
Router::start(); // must be the last call
Where to look first:
Pecee\SimpleRouter\SimpleRouter — the static facade (get, post, group, start, request, response). This is your primary API surface and maps almost 1:1 to the Laravel Route facade.Pecee\SimpleRouter\Route\Route — the fluent chain returned by each verb: ->name(), ->where(), ->middleware(), ->domain().Pecee\Http\Request / Pecee\Http\Response — no Laravel Illuminate\Http\Request; these are the objects your middleware and handlers receive.Pecee\Http\Middleware\BaseCsrfVerifier — if you need CSRF, this is the starting point (there is no @csrf directive; you render the token yourself).First real use case: replace a legacy switch ($_SERVER['PHP_SELF']) block. Point your web server's rewrite to index.php, define three or four routes, call start(), and you have typed parameters, 404 handling, and a place to hang middleware.
Grouping with prefix + middleware — same shape as Laravel:
Router::group(['prefix' => '/admin', 'middleware' => AuthMiddleware::class], function () {
Router::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
Router::post('/users', [UserController::class, 'store']);
});
Controller action syntax. Two accepted forms: 'App\Controllers\UserController@show' or [UserController::class, 'show']. Unlike Laravel, the container does not instantiate controllers — the router does new $controllerClass and invokes the method. If a controller needs dependencies, either resolve them inside the method via your own container, or use a boot manager:
class ContainerBoot implements IRouterBootManager
{
public function boot(Route $route) { /* set up container, session, etc. */ }
}
Router::setBootManager(new ContainerBoot());
Route parameters and constraints. Match Laravel's {id} and ->where():
Router::get('/posts/{id}', [PostController::class, 'show'])->where(['id' => '[0-9]+']);
Router::get('/posts/{slug?}', [PostController::class, 'bySlug']); // optional
Inline regex — '/posts/{id:[0-9]+}' — is also supported and useful for quick constraints without chaining.
Middleware is not aliased by default. Unlike Laravel's web/auth groups, you register classes by FQCN or wrap them in a group. Implement Pecee\Http\Middleware\IMiddleware::handle(Request $request). Return null to continue, or a Response to short-circuit — this differs from Laravel, where a middleware returns the request to proceed.
class AuthMiddleware implements IMiddleware
{
public function handle(Request $request)
{
if (!$_SESSION['user'] ?? null) {
return (new Response())->redirect('/login', 302);
}
return null; // continue
}
}
Responses. Controllers and closures may return a string (auto-wrapped), an array (serialized as JSON), or an explicit Response/JsonResponse/RedirectResponse object. Use the object form when you need status codes or headers — return new JsonResponse($data, 201) rather than setting headers globally.
Named routes for URL generation. ->name('user.show') then Router::getUrl('user.show', ['id' => 5]) in views or redirects. Name the root route (->name('home')) if you generate links back to / — generating URLs for home/root routes was broken before 5.4.1.7, so pin at least that patch if getUrl() on your index route matters. This requires request context (scheme/host), so it behaves differently in CLI — pass an absolute URL context or accept the relative fallback.
CSRF. Call Router::csrfVerifier(new BaseCsrfVerifier()) during boot. The verifier checks a token in the request body or a header; you must expose the token yourself in templates (read it from the request/session — there's no Blade directive). Exempt endpoints by overriding the verifier's excluded path patterns.
Custom exception handling. The default handler renders a debug page; in production you want your own:
Router::setExceptionHandler(new class implements IExceptionHandler {
public function handleException(RouterException $e, ?IRoute $route = null) { /* render 404/500 */ }
});
Route file organization. For anything non-trivial, split routes by concern and require them from a central file. There is no php artisan route:list equivalent; a small dev-only endpoint that reflects Router::getRoutes() is a common substitute.
/users/{id} after /users/create.Router::start() must be the absolute last call. Any output before it (whitespace, BOM, echo) blocks headers and breaks redirects/responses. This is a common "works locally, fails on server" bug.Request $request in a controller works; a service class or repository does not. Plan for manual construction or a boot manager — a persistent friction point if you're used to Laravel's container.null continues; returning a Response stops. Returning the request object does nothing useful. This trips up developers porting Laravel middleware.@csrf / no Blade. Tokens must be injected in your template engine manually. Check BaseCsrfVerifier for the storage key it reads from.Router::setDefaultNamespace(), they'll resolve to the global namespace and fail with a cryptic "controller not found."'/foo' and '/foo/', or normalise REQUEST_URI in your bootstrap. Encoded parameters (%20) are decoded by the router — apply where constraints if the value shape matters.getUrl() needs request context. It reads the current host/scheme, so calling it during a CLI script or queue worker silently yields an incomplete URL. Prefer absolute URLs built from a configured base in background code. Home/root URLs (getUrl('home') where the route path is /) were fixed in 5.4.1.7 — if you're on an earlier 5.x patch, upgrade rather than working around it with a hardcoded /.Router::getRequest()->getMethod() and getUri() first — the request target is often the culprit (subdirectory installs, reverse-proxy X-Forwarded-* not trusted). Second, inspect Router::getRoutes() to verify registration order and namespaces.IRouterBootManager (per-route boot), IExceptionHandler (error rendering), RouteController (subclass to control controller instantiation — useful for wiring a container), and custom BaseCsrfVerifier subclasses (excluded paths, token header name).$_SERVER-based SAPI. Under PHP's built-in server, REQUEST_URI handles rewrites; under nginx/Apache you must forward all unmatched paths to index.php, or the router only ever sees /.How can I help you explore Laravel packages today?