A fast, file-based AJAX router for PHP — powered by page.js on the client and a slim PHP resolver on the server.
Built and maintained by Euan Livingstone. Originally forked from Coasters & Crafters Ltd (now Bundle Group Ltd).
You get Next.js-style file routing with plain PHP pages, without a heavy framework. Navigate client-side, render server-side fragments, and keep URLs, params, and query strings first-class.
Forked from Coasters & Crafters Ltd (now Bundle Group Ltd). Notable differences:
| Area | Original | This fork |
|---|---|---|
| Query strings | Not forwarded to PHP | ?key=value available as $_QUERY / $_GET |
| Client cache | Every navigation hit the network | In-memory GET cache for repeat / back-forward visits |
| Prefetch | None | Hover prefetch on same-origin links |
| Race handling | Overlapping fetches could clash | In-flight requests aborted on new navigation |
| Hooks | None | Router.on('beforeNavigate' | 'afterNavigate') |
| Public API | Fetch-only | Router.prefetch(), Router.clearCache() |
| Loading UX | None | router-loading class on the content root |
| Page scripts | <script> in fragments did not run |
Inline + external scripts re-executed on each navigation |
| Active nav | Unused navBarWrapperSelector |
Auto active + aria-current on matching nav links |
| PHP resolver | Simple path parse, no guards | Cached path resolve, path-traversal block, strict_types |
| page.js CDN | rawgit (deprecated) | unpkg |
| License / credit | © Coasters & Crafters Ltd | © Euan Livingstone, with original fork attribution |
File-based routing (pages/files, [param] segments, customRoutes in router.js) is unchanged in spirit — same mental model, faster and safer plumbing.
- File-based routes under
pages/files(including[param]segments) - Query strings —
?this=thatis available in PHP as$_QUERYand$_GET - Route params —
/users/:id→$_PARAMS['id'] - In-memory page cache — back/forward and repeat visits feel instant
- Prefetch on hover — links warm the cache before you click
- Abort in-flight fetches — rapid clicks don’t race
- Navigation hooks —
Router.on('beforeNavigate' | 'afterNavigate', fn) - Page scripts — inline and external
<script>tags in page fragments run on every visit - Active nav markers — underline / style the current page link in navbars
- Path traversal protection and stricter request handling on the PHP side
git clone https://github.com/einc123/php-router.gitPoint your web server so unknown paths fall through to index.php (see page.js server configuration).
php -S localhost:8080Open http://localhost:8080.
https://example.com/help → create pages/files/help.php.
https://example.com/users/1 → create pages/files/users[id].php, then register the route in assets/js/router.js:
const customRoutes = [
'/users/:id',
];In PHP:
<?php echo htmlspecialchars($_PARAMS['id'], ENT_QUOTES, 'UTF-8'); ?>Multiple params: settings[name][value].php with route /settings/:name/:value.
Nested routes: use a folder like users[id]/ with index.php (and optional child files such as email.php).
https://example.com/news/42?tab=comments
$_PARAMS['id']; // "42"
$_QUERY['tab']; // "comments"
$_GET['tab']; // also "comments"const resp = await fetch('/pages/router.php?path=/your/path/in/router.js', {
method: 'POST',
body: JSON.stringify({ your: 'body' }),
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
});After mutating data, clear cached HTML if needed:
Router.clearCache(); // all
Router.clearCache('/users/1'); // one keyRouter.on('beforeNavigate', (ctx) => {
console.log('leaving for', ctx.path);
});
Router.on('afterNavigate', (ctx) => {
document.title = 'App — ' + ctx.path;
});
Router.prefetch('/news/1?tab=latest');Mark a nav with data-router-nav (or use .navbar-nav). On each navigation the best-matching link gets class active and aria-current="page".
<nav data-router-nav>
<a href="/">Home</a>
<a href="/news/1">News</a>
<a href="/users/1">Users</a>
</nav>[data-router-nav] a.active {
text-decoration: underline;
/* or border-bottom, color, etc. */
}/matches exact by default; other links match as a prefix (/users→/users/1/email).- Override per link with
data-active-match="exact"ordata-active-match="prefix". - Use
data-active-path="/news"when the linkhrefis a concrete URL but the section should stay active for all/news/...pages. - Longest match wins when several links could be active.
- Optional:
data-active-parent="li"also addsactiveto a parent element. - Call
Router.setActive()after you inject a navbar dynamically.
Tune selectors / class name at the top of assets/js/router.js (navSelector, activeClass).
Browsers ignore <script> tags inserted via innerHTML. This router re-runs them after each navigation (inline and src), in order.
<button type="button" id="go">Click</button>
<script src="/assets/js/pages/news.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => alert('works'));
</script>- Use
data-router-onceon an external script to load that URL only once (shared libraries). - Listen for
router:leave/router:enteron<main>to clean up listeners or timers when leaving a page.
document.querySelector('main').addEventListener('router:leave', () => {
// remove listeners, clear intervals, etc.
});Edit pages/404.html.
In pages/router.php, uncomment / set:
require_once __DIR__ . '/bootstrap.php';MIT © Euan Livingstone. See license.
Originally forked from Coasters & Crafters Ltd (now Bundle Group Ltd).