Router

Overview

The Router gives you clean, pattern-based URL routing as a fully optional feature. By default JiFramework runs in file-based mode — PHP files are accessed directly, no routing needed. Flip one config key and index.php becomes the single entry point for every request.

  • Class: JiFramework\Core\Http\Router
  • Access: $app->router — returns null when router is disabled (no overhead)
  • Handler types: closure or PHP file path — both work identically
  • URL parameters: {param} placeholders, extracted and injected automatically
  • Methods: GET, POST, PUT, DELETE, PATCH — plus HTML form method override via _method
// index.php
$app = new App();

$app->router->get('/',            'pages/home.php');
$app->router->get('/about',       'pages/about.php');
$app->router->get('/users/{id}',  'pages/user.php');
$app->router->post('/users',      'pages/user_create.php');

$app->router->dispatch();

Enabling the Router

Two steps to switch from file-based mode to router mode.

Step 1 — jiconfig.php

Set router_enabled to true. If your project lives in a subdirectory (e.g. localhost/myapp/), also set router_base_path.

// config/jiconfig.php
return [
    'router_enabled'   => true,
    'router_base_path' => '',          // running at domain root
    // 'router_base_path' => '/myapp', // running at localhost/myapp/
];
Step 2 — .htaccess

The framework ships with a ready-to-use .htaccess. You do not need to edit it — it already redirects all non-file, non-directory requests to index.php:

RewriteEngine On

# Protect sensitive directories
RewriteRule ^(storage|src)/ - [F,L]

# Send all requests to index.php (real files/dirs served directly)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

Static assets (CSS, JS, images) are served directly by Apache/Nginx — only unknown paths go through index.php.

Step 3 — index.php

Define all routes in index.php at your project root, then call dispatch() at the end:

<?php
require 'vendor/autoload.php';

$app = new JiFrameworkCoreAppApp();

$app->router->get('/', 'pages/home.php');
$app->router->get('/contact', 'pages/contact.php');

$app->router->dispatch();

get() / post() / put() / delete() / patch()

Register a route for a specific HTTP method. All five methods share the same signature and return $this for chaining.

get(string $pattern, callable|string $handler): static
post(string $pattern, callable|string $handler): static
put(string $pattern, callable|string $handler): static
delete(string $pattern, callable|string $handler): static
patch(string $pattern, callable|string $handler): static
  • $pattern(string) URL pattern. Use {name} for dynamic segments. Static segments with special characters (dots, plus signs) are treated as literals.
  • $handler(callable|string) A closure or a relative/absolute path to a PHP file.
$router = $app->router;

$router->get('/',              'pages/home.php');
$router->get('/users',         'pages/users/index.php');
$router->post('/users',        'pages/users/create.php');
$router->get('/users/{id}',    'pages/users/show.php');
$router->put('/users/{id}',    'pages/users/update.php');
$router->delete('/users/{id}', 'pages/users/delete.php');
$router->patch('/users/{id}',  'pages/users/patch.php');

$router->dispatch();

any()

any(string $pattern, callable|string $handler): static

Register a route that responds to any HTTP method (GET, POST, PUT, DELETE, PATCH). Useful for health check endpoints, webhooks, or catch-all handlers where the method does not matter.

// Health check endpoint -- responds to GET, POST, or any method
$app->router->any('/ping', function() {
    header('Content-Type: application/json');
    echo json_encode(['status' => 'ok', 'time' => time()]);
});

// Webhook receiver -- accept any method from the provider
$app->router->any('/webhooks/payment', 'pages/webhooks/payment.php');

$app->router->dispatch();

match()

match(string|array $methods, string $pattern, callable|string $handler): static

Register a route that responds to a specific set of HTTP methods. Use when a single handler serves more than one method but not all.

  • $methods(string|array) One or more HTTP method names, e.g. ['GET', 'POST'] or 'GET'.
  • $pattern(string) URL pattern.
  • $handler(callable|string) Closure or PHP file path.
// Show and submit a contact form with the same file
$app->router->match(['GET', 'POST'], '/contact', 'pages/contact.php');

// Inside contact.php -- check the method
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // handle form submission
} else {
    // render the form
}

redirect()

redirect(string $from, string $to, int $status = 302): static

Register a redirect route. When a request matches $from, the visitor is immediately redirected to $to with the given HTTP status code. No handler file needed.

  • $from(string) URL pattern to match (supports {param} placeholders).
  • $to(string) Destination URL.
  • $status(int) HTTP status code. Use 301 for permanent, 302 for temporary (default).
// Permanent redirect -- old URL moved to new location (SEO-safe)
$app->router->redirect('/home',         '/',             301);
$app->router->redirect('/blog',         '/posts',        301);

// Temporary redirect
$app->router->redirect('/sale',         '/products',     302);

// Redirect before dispatch
$app->router->redirect('/old-contact',  '/contact',      301);
$app->router->get('/contact', 'pages/contact.php');

$app->router->dispatch();

group()

group(string $prefix, callable $callback): static

Group routes under a shared URL prefix. All routes registered inside the callback will have the prefix prepended automatically. Groups can be nested for sub-prefixes.

  • $prefix(string) The URL prefix to prepend, e.g. '/api/v1'.
  • $callback(callable) A function that receives the router instance and registers routes.
$app->router->group('/api/v1', function($r) {

    // GET  /api/v1/users
    $r->get('/users', 'api/v1/users/index.php');

    // POST /api/v1/users
    $r->post('/users', 'api/v1/users/create.php');

    // GET  /api/v1/users/{id}
    $r->get('/users/{id}', 'api/v1/users/show.php');

    // Nested group -- /api/v1/admin/...
    $r->group('/admin', function($r) {
        $r->get('/stats',   'api/v1/admin/stats.php');
        $r->get('/reports', 'api/v1/admin/reports.php');
    });

});

$app->router->dispatch();

Groups keep index.php clean when you have many routes sharing a common prefix. The prefix is applied only while inside the callback — routes defined outside are not affected.

URL Parameters

Use {name} placeholders in a pattern to capture dynamic URL segments. The router extracts and URL-decodes each value automatically.

Multiple parameters per pattern are supported. Each segment matches any character except a forward slash /.

In a closure handler

Parameters are passed as positional arguments in the order they appear in the pattern.

$app->router->get('/users/{id}', function($id) {
    echo "User: $id";
});

$app->router->get('/posts/{year}/{slug}', function($year, $slug) {
    echo "Post: $slug from $year";
});
In a file handler

Parameters are extracted into local variables using the same name as the placeholder. $app is also available.

// Route definition
$app->router->get('/users/{id}', 'pages/user.php');

// pages/user.php
// Available variables: $app, $id
$user = $app->db->table('users')->where('id', $id)->first();
if (!$user) {
    $app->abort(404, 'User not found');
}
Example patterns
$app->router->get('/products/{id}',              ...); // /products/42
$app->router->get('/blog/{year}/{month}/{slug}',  ...); // /blog/2024/03/hello-world
$app->router->get('/files/{filename}',            ...); // /files/report.pdf
$app->router->get('/api/v1.0/users/{id}',         ...); // static dot treated as literal

File Handlers

When a handler is a string, the router treats it as a path to a PHP file and requires it when the route matches. This keeps the traditional file-per-page structure while adding clean URL routing on top.

Relative paths are resolved from your project root (Config::$basePath). Absolute paths are used as-is.

Available variables inside the file
  • $app — the full App instance with access to $app->db, $app->session, etc.
  • $id, $slug, etc. — any URL parameters defined in the pattern.
// index.php
$app->router->get('/dashboard',         'pages/dashboard.php');
$app->router->get('/posts/{slug}',      'pages/post.php');
$app->router->get('/admin/users/{id}',  'pages/admin/user_detail.php');

// pages/post.php
$post = $app->db->table('posts')
    ->where('slug', $slug)
    ->where('published', 1)
    ->first();

if (!$post) {
    $app->abort(404);
}

include 'views/post.php';

A 404 HttpException is thrown automatically if the file does not exist on disk.

Closure Handlers

When a handler is a callable (closure, function name, or [$object, 'method']), the router calls it directly with URL parameters as positional arguments.

Use closures for lightweight routes — API endpoints, health checks, JSON responses — where a full page file would be overkill.

// Simple JSON API endpoint
$app->router->get('/api/status', function() use ($app) {
    $app->json(200, ['status' => 'ok', 'version' => '1.0']);
});

// URL parameter injected as first argument
$app->router->get('/api/users/{id}', function($id) use ($app) {
    $user = $app->db->table('users')->find((int)$id);
    if (!$user) {
        $app->abort(404, 'User not found');
    }
    $app->json(200, ['user' => $user]);
});

// Multiple parameters -- matched positionally
$app->router->get('/api/posts/{year}/{slug}', function($year, $slug) use ($app) {
    $post = $app->db->table('posts')
        ->where('year', $year)
        ->where('slug', $slug)
        ->first();
    $app->json(200, ['post' => $post]);
});

Use use ($app) to bring the App instance into the closure scope.

HTML Form Method Override

HTML forms only support GET and POST. The router supports a _method override field so you can send PUT, DELETE, and PATCH requests from standard HTML forms.

Add a hidden _method input inside any POST form. The router detects it and dispatches to the correct route.

// Route definition
$app->router->delete('/posts/{id}', 'pages/posts/delete.php');
$app->router->put('/posts/{id}',    'pages/posts/update.php');
<!-- Delete form -->
<form method="POST" action="/posts/42">
    <input type="hidden" name="_method" value="DELETE">
    <button type="submit">Delete Post</button>
</form>

<!-- Update form -->
<form method="POST" action="/posts/42">
    <input type="hidden" name="_method" value="PUT">
    <input type="text" name="title" value="My Post">
    <button type="submit">Save</button>
</form>

Only PUT, DELETE, and PATCH are accepted as override values. Any other value is ignored and the request is treated as a normal POST.

dispatch()

dispatch(): void

Match the current HTTP request (method + URI) against all registered routes and execute the matching handler. Always call this after all routes are defined — it is the last line in index.php.

If no route matches, a HttpException(404) is thrown and handled by the framework's error handler automatically.

The URI is normalised before matching:

  • Query string (?foo=bar) is stripped — use $_GET or $app->request->queryParam() to access it.
  • The router_base_path prefix is removed (for subdirectory installs).
  • Trailing slashes are removed; a leading slash is always present.
// index.php -- dispatch() is always last
$app = new App();

$app->router->get('/', 'pages/home.php');
$app->router->get('/about', 'pages/about.php');
$app->router->group('/api', function($r) {
    $r->get('/users', 'api/users.php');
});

$app->router->dispatch(); // <-- always last

Without Router — File Mode

With router_enabled = false (the default), the router is never instantiated and $app->router returns null. There is no index.php entry point — each PHP file is accessed directly by the browser, exactly like traditional PHP.

This mode requires no changes to .htaccess and is ideal for simple projects, admin panels, or when you prefer direct file access.

// config/jiconfig.php
return [
    'router_enabled' => false,  // default
];

// Project structure -- files accessed directly:
// /index.php          → yoursite.com/index.php
// /pages/about.php    → yoursite.com/pages/about.php
// /pages/user.php     → yoursite.com/pages/user.php?id=42

// pages/user.php
require 'vendor/autoload.php';
$app = new JiFrameworkCoreAppApp();

$id = (int) ($_GET['id'] ?? 0);
$user = $app->db->table('users')->find($id);

You can always switch between modes by changing the single config key — the rest of the framework works identically in both.

Examples

Complete real-world examples.

Full web application index.php
<?php
require 'vendor/autoload.php';
$app = new JiFrameworkCoreAppApp();

$r = $app->router;

// Public pages
$r->get('/',        'pages/home.php');
$r->get('/about',   'pages/about.php');
$r->match(['GET', 'POST'], '/contact', 'pages/contact.php');

// Blog
$r->get('/blog',             'pages/blog/index.php');
$r->get('/blog/{slug}',      'pages/blog/post.php');

// Legacy redirect
$r->redirect('/news', '/blog', 301);

// Auth
$r->get('/login',  'pages/auth/login.php');
$r->post('/login', 'pages/auth/login_post.php');
$r->any('/logout', 'pages/auth/logout.php');

// REST API
$r->group('/api/v1', function($r) {
    $r->get('/users',        'api/users/index.php');
    $r->post('/users',       'api/users/create.php');
    $r->get('/users/{id}',   'api/users/show.php');
    $r->put('/users/{id}',   'api/users/update.php');
    $r->delete('/users/{id}', 'api/users/delete.php');

    $r->get('/posts',        'api/posts/index.php');
    $r->get('/posts/{id}',   'api/posts/show.php');
});

// Health check
$r->any('/ping', function() {
    header('Content-Type: application/json');
    echo json_encode(['status' => 'ok']);
});

$r->dispatch();
API file with URL parameter (api/users/show.php)
<?php
// $app and $id are available automatically
$user = $app->db->table('users')->find((int)$id);

if (!$user) {
    $app->abort(404, 'User not found');
}

$app->json(200, ['user' => $user]);
Subdirectory install (localhost/myproject/)
// config/jiconfig.php
return [
    'router_enabled'   => true,
    'router_base_path' => '/myproject',
];

// Routes are written without the prefix:
$app->router->get('/', 'pages/home.php');
// Matches: localhost/myproject/