AI blog · DotApp PHP Framework 2.0
How to build AJAX lists with pagination in DotApp PHP Framework
A Shop catalog that can grow must ship SQL paginate() and an interactive pager on the first version — even when the table is empty today.
Further pages stay on the screen: $dotapp().load(), a visible busy overlay on desktop and mobile, then a patch of the inner HTML (rows and pager together).
This article is a complete, Installation-free Shop items list: controller, fragment view, overlay CSS, and page script with search.
Common mistakes
| Wrong | Right |
|---|---|
Dump the table with ->all() because there are only three rows now. |
DB::module('RAW')->q(...)->paginate($perPage, $page) on the first ship. |
<a href="?page=2"> or location.reload() after a click. |
type="button" plus $dotapp().load(); stay on the page. |
Replace #listWrap inner HTML (the overlay node). |
Cover #listWrap; patch the child #listInner (rows and pager). |
Bind pager clicks with .on() once at boot. |
Bind with .live() so new buttons still work after a patch. |
Wrap Prev / Next in <fo-rm>, or skip the overlay on a phone. |
Pager is buttons only. Overlay intercepts pointer and touch until success and error. |
Raw ids in data-id="7", or filter ->all() in JavaScript. |
{{ enc(Shop.item.id): $id }} with a unique key2; search in SQL with LIKE + paginate(). |
When to paginate
Paginate any list that can accumulate: items, orders, users, logs, messages, files, events. Skip a pager only for a set that is closed by product design (four fixed cards). Lookup lists (catalog, articles, customers, orders by number) also ship interactive search unless you explicitly decline it. Query builder details: How to use the database in DotApp PHP Framework.
Overlay while the request runs
DotApp core does not ship a page overlay — build it in the Shop module.
The wrapper is position: relative. While load() runs, a busy class covers the whole list on desktop and mobile, intercepts pointer and touch, and is removed on success and error.
Patch a stable child. Replacing the wrapped node’s HTML wipes the overlay.
load()
load() posts { data, crc } with the framework headers. PHP calls crcCheck() before it trusts the body.
How that channel is built: How DotApp PHP Framework protects the browser-to-PHP channel.
When to choose load() over a full <fo-rm>: Secure backend-frontend communication in DotApp PHP Framework.
paginate() return keys
SQL paginate() returns data, current_page, per_page, total, last_page, from, to, has_more_pages, prev_page, next_page.
Clamp the requested page to 1 … last_page after the first query.
UI chrome is separate: Pagination::paginate($current, $last)->render(...) must emit type="button", never a document-reloading href.
Complete controller (no Installation.php)
File: app/modules/Shop/Controllers/Items.php.
Register GET /shop/items for first paint and POST /shop/items/list for every later page and search.
Staff lists call Auth::can(); a public catalog can skip that line but never skip crcCheck() on the POST.
Controllers are public static — there is no $this.
<?php
namespace Dotsystems\App\Modules\Shop\Controllers;
use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Auth;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\DB;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Pagination;
use Dotsystems\App\Parts\Renderer;
class Items extends \Dotsystems\App\Parts\Controller
{
public static function index($request)
{
$html = Renderer::new()
->module('Shop')
->setView('items/page')
->setViewVar('title', 'Items')
->setViewVar('listHtml', self::renderList(1, ''))
->renderView();
if ($html === '') {
Logger::use()->error('Shop items page produced empty output');
return new \Dotsystems\App\Parts\Response(500, 'Template error');
}
return $html;
}
public static function list($request)
{
if (!$request->crcCheck()) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Bad request'], 400);
}
if (!Auth::can(['Shop.catalog'])) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Forbidden'], 403);
}
$payload = $request->data(true)['data'] ?? [];
$pageNo = (int) ($payload['page'] ?? 1);
$q = trim((string) ($payload['q'] ?? ''));
if (strlen($q) > 80) {
$q = substr($q, 0, 80);
}
if (strlen($q) < 3) {
$q = '';
}
$html = self::renderList($pageNo, $q);
if ($html === '') {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Template error'], 500);
}
return DotApp::DotApp()->ajaxReply(['status' => 1, 'html' => $html], 200);
}
private static function likeContains(string $q): string
{
return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q) . '%';
}
private static function highlight(string $text, string $q): string
{
$safe = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
if ($q === '') {
return $safe;
}
$needle = htmlspecialchars($q, ENT_QUOTES, 'UTF-8');
return preg_replace('/(' . preg_quote($needle, '/') . ')/i', '<mark>$1</mark>', $safe) ?? $safe;
}
private static function fetchPage(int $pageNo, string $q, int $perPage): array
{
return DB::module('RAW')
->q(function ($qb) use ($q) {
$qb->select(['id', 'title', 'sku'])->from('shop_items')->orderBy('title', 'ASC');
if ($q !== '') {
$qb->where('title', 'LIKE', self::likeContains($q));
}
})
->paginate($perPage, $pageNo);
}
private static function renderList(int $pageNo, string $q): string
{
if ($pageNo < 1) {
$pageNo = 1;
}
$perPage = (int) (Config::module('Shop', 'itemsPerPage') ?? 20);
$page = self::fetchPage($pageNo, $q, $perPage);
$last = max(1, (int) $page['last_page']);
if ($pageNo > $last) {
$pageNo = $last;
$page = self::fetchPage($pageNo, $q, $perPage);
}
$rows = [];
foreach ($page['data'] as $row) {
$rows[] = [
'id' => $row['id'],
'title_html' => self::highlight((string) $row['title'], $q),
'sku' => (string) $row['sku'],
];
}
$emptyKind = ((int) $page['total'] === 0) ? ($q === '' ? 'catalog' : 'search') : 'none';
$pager = Pagination::paginate((int) $page['current_page'], (int) $page['last_page'])
->window(2)->arrows(true)->ellipsis(true)->edge(true)
->render(function ($type, $pageNo, $label, $state, $href) {
if ($type === 'ellipsis') {
return '<li class="disabled"><span>…</span></li>';
}
$off = ($state === 'active' || $state === 'disabled') ? ' disabled' : '';
return '<li class="' . $state . '"><button type="button" class="js-shop-page" data-page="'
. (int) $pageNo . '"' . $off . '>' . htmlspecialchars((string) $label, ENT_QUOTES, 'UTF-8')
. '</button></li>';
});
return Renderer::new()
->module('Shop')
->setView('items/list')
->setViewVar('rows', $rows)
->setViewVar('emptyKind', $emptyKind)
->setViewVar('pager', $pager)
->renderView();
}
}
Complete list.view.php fragment
File: app/modules/Shop/views/items/list.view.php.
This inner HTML is what load() replaces. Row ids use a unique key2 Shop.item.id — decrypt with that same string, treat false as reject, and still call Auth::can() on any later row action.
{{ var: }} is a raw echo: pass already-escaped title HTML from PHP.
{{ if $emptyKind === "catalog" }}
<p class="shop_empty">No items yet.</p>
{{ elseif $emptyKind === "search" }}
<p class="shop_empty">No items match this search.</p>
{{ else }}
<table class="shop_table">
<thead>
<tr><th>Title</th><th>SKU</th></tr>
</thead>
<tbody>
{{ foreach $rows as $item }}
<tr data-item="{{ enc(Shop.item.id): $item['id'] }}">
<td>{{ var: $item['title_html'] }}</td>
<td>{{ var: $item['sku'] }}</td>
</tr>
{{ /foreach }}
</tbody>
</table>
<nav class="shop_pager" aria-label="Items pages">
<ul>{{ var: $pager }}</ul>
</nav>
{{ /if }}
Page shell (items/page.view.php): search input outside #listInner, list wrapper around the fragment, then /assets/dotapp/dotapp.js and the module script.
{{ var: $listHtml }} fills first paint with page 1.
<input type="search" id="shopSearch" class="shop_search" placeholder="Search items…" autocomplete="off">
<p id="shopStatus" hide="true"></p>
<div id="listWrap" class="shop_listwrap">
<div id="listInner">{{ var: $listHtml }}</div>
</div>
<script src="/assets/dotapp/dotapp.js"></script>
<script src="/assets/modules/Shop/js/shop_list.js"></script>
Complete overlay CSS
File: app/modules/Shop/assets/css/shop_list.css — classes shop_*.
::after intercepts pointer and touch; ::before is a spinner large enough on a phone.
.shop_listwrap { position: relative; min-height: 8rem; }
.shop_listwrap.shop_busy::after {
content: ""; position: absolute; inset: 0; z-index: 2;
background: rgba(255,255,255,.72); cursor: wait; pointer-events: all;
}
.shop_listwrap.shop_busy::before {
content: ""; position: absolute; z-index: 3; inset: 50% auto auto 50%;
width: 2.25rem; height: 2.25rem; margin: -1.125rem 0 0 -1.125rem;
border: 3px solid #ccc; border-top-color: #222; border-radius: 50%;
animation: shop_spin .7s linear infinite; pointer-events: none;
}
@keyframes shop_spin { to { transform: rotate(360deg); } }
.shop_table thead th { position: sticky; top: 0; background: #fff; }
Complete JavaScript
File: app/modules/Shop/assets/js/shop_list.js.
Boot on dotapp (or immediately if window.$dotapp exists). Always parseReply() — ajaxReply is base64 JSON.
Search: debounce ~300 ms, from 3 characters; shorter or empty is unfiltered page 1. Keep q on every pager POST. New query sends page: 1.
Client API: How to use $dotapp() JavaScript in DotApp PHP Framework.
(function () {
var runMe = function ($dotapp) {
var listBusy = false;
var currentQuery = "";
var searchTimer = null;
function listDone() {
listBusy = false;
$dotapp("#listWrap").removeClass("shop_busy");
}
function loadList(page) {
if (listBusy) return;
listBusy = true;
$dotapp("#listWrap").addClass("shop_busy");
$dotapp().load("/shop/items/list", "POST", { page: page, q: currentQuery },
function (raw) {
var reply = $dotapp().parseReply(raw);
if (reply && reply.status == 1 && reply.html) $dotapp("#listInner").html(reply.html);
listDone();
},
function () { listDone(); }
);
}
$dotapp().live("click", ".js-shop-page", function (e) {
var page = parseInt($dotapp(e.currentTarget).attr("data-page"), 10) || 1;
loadList(page);
});
$dotapp().live("input", "#shopSearch", function () {
var q = ($dotapp("#shopSearch").val() || "").trim();
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
currentQuery = q.length >= 3 ? q : "";
loadList(1);
}, 300);
});
};
if (window.$dotapp) runMe(window.$dotapp);
else window.addEventListener("dotapp", function () { runMe(window.$dotapp); }, { once: true });
})();
FAQ
Must page 1 be AJAX too?
No. First paint may be server-rendered page 1 inside #listInner. Every later page and every search POST uses load().
Why not a form around the pager?
<fo-rm> is for a real multi-field submit. Pager, toggle, delete, and reorder are one-shot actions: type="button" plus load().
Can I filter the current HTML in JavaScript?
No. Bind LIKE, escape % and _ in the user string, cap length, and paginate in SQL. Never concatenate $q into a query string.
Empty overlay or empty table?
Remove the busy class on error as well as success (400/403/404/429 still uncover the list). Zero rows: product copy plus a primary action. Search with total 0: “No items match …”. Never a naked blank table.