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.
Search text and other payload fields come from $request->data(true)['data'] — Request data.
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(). |
"WHERE title LIKE '%".$q."%'" concatenated into SQL |
QueryBuilder where('title', 'LIKE', $like), or named RAW WHERE title LIKE :q with ['q' => $like]. |
$request->data() for the search string |
$request->data(true)['data']['q']. % in the protected copy is rewritten. Request data. |
Empty load() error callback — HTTP 400 shows nothing |
parseReply the error text, show message, then listDone(). |
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.
QueryBuilder is the usual wrap for paginate() (it can add LIMIT / OFFSET for you).
Row lookups and writes are equally fine as named RAW: WHERE id = :iduser plus ['iduser' => $id] — values stay in the array, not glued into the string.
Both styles: 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.
Payloads ride the working key (per-session + app.c_enc_key + extra keys + key update) — the framework builds it.
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));
}
// Named RAW is the same binding story for a single row:
// $qb->raw('SELECT id, title, sku FROM shop_items WHERE id = :iduser LIMIT 1', ['iduser' => $id]);
})
->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 (status, errorText) {
var reply = $dotapp().parseReply(errorText);
if (reply && typeof reply === "object" && reply.message) {
$dotapp("#shopStatus").attr("hide", "false").html(reply.message);
}
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
Why data(true) for q?
Search text is a real string. data() rewrites % and other characters.
Read $request->data(true)['data']['q']. Details: Request data.
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.
QueryBuilder where('title', 'LIKE', $like) or named RAW WHERE title LIKE :q with ['q' => $like] are both bound.
Never concatenate $q into the SQL 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.