ThinkPHP分页必须由paginate()接管原始查询链,不能在select()/all()结果上调用;需显式传入false禁用自动解析以支持自定义参数;render()前不可调用toArray()等方法;复杂查询下total()可能不准,应手动传入总数。

ThinkPHP 的分页功能,说起来好像挺简单,一个方法就能搞定。但实际用起来,不少人会栽跟头:要么报错 Call to undefined method think\Collection::paginate(),要么分页数据半天出不来,或者翻页逻辑完全乱套。问题的根源,几乎都集中在 paginate() 这个方法的调用时机和上下文上。简单来说,它必须接管原始查询链,否则一切免谈。
paginate() 必须链式调用,不能在数组或集合上调用
一个非常典型的错误是,先通过 select() 或 all() 把数据全取出来,然后才想起来分页:
User::where('status', 1)->select()->paginate(10)❌ —— 这其实就是对一个普通 PHP 数组进行分页,根本走不了数据库的LIMIT,性能差得离谱,而且总数统计完全不准确。User::all()->paginate(10)❌ ——all()返回的是一个think\Collection对象,它本身并没有paginate()方法,所以这行代码就是直接报错。User::where('status', 1)->paginate(10)✅ —— 这才是正确的姿势。查询构造器此时尚未真正执行,paginate()才能顺理成章地插入COUNT和LIMIT逻辑,生成一条完整的分页 SQL。
手动控制 page 和 list_rows 时别漏掉 false 参数
默认情况下,paginate(10) 会自动从 $_GET['page'] 里读取当前页码,但不会去碰每页显示条数。假设你想通过 ?page=2&size=20 这样的参数来控制分页,就必须显式地关闭自动解析:
- 坑点:如果不加
false,比如User::paginate(input('size/d', 15), true, ['page' => input('page/d', 1)]),那么page参数会被解析两次,结果就是翻页跳转逻辑错乱,让你一头雾水。 - 正确的写法:
$size = input('size/d', 15); User::paginate($size, false, ['page' => input('page/d', 1)])。这个false参数的意思就是告诉框架:“别管自动解析,我亲自来。” - 顺带提一句,如果你需要通过
query透传搜索参数(比如keyword),也要一并塞进第三个参数里:['query' => ['keyword' => $keyword], 'page' => ...]。不然翻页后搜索条件就丢了。
render() 前千万别调用 toArray() 或 json()
$users->render() 这个方法,依赖于分页对象的完整上下文——总条数、URL 配置、当前页等。一旦你在它之前做了下面这些动作,render() 就会彻底罢工:
$users->toArray()—— 返回一个纯数组,分页的元信息全丢了。json($users)或return $users(API 场景)—— 对象被序列化后,render()就再也调不到了。foreach ($users as $u) { ... }后再render()—— 这种行为未定义,多数情况下会静默失败,连个错误提示都没有。
对于 API 场景,建议单独提取分页字段:['data' => $users->items(), 'total' => $users->total(), 'per_page' => $users->listRows(), 'current_page' => $users->currentPage()]。这样既拿到了数据,又不影响 render() 的后续调用。
total() 是缓存值,复杂查询下可能不准
需要区分清楚:$users->count() 返回的是当前页的数据量(比如 10 条),而不是全表总数;真正起作用的,是 $users->total(),它才是分页时那条 COUNT 查询的结果。但这里有个隐藏陷阱:
- 在子查询、
UNION、LEFT JOIN等复杂查询场景下,框架对COUNT的生成逻辑可能会误判,导致total()结果偏小,甚至直接返回 0。 - 遇到这种情况,比较稳妥的做法是手动查询总数:
$total = User::where(...)->count();,然后显式传给paginate:paginate(15, false, ['total' => $total])。 - 另外,
$users->lastPage()这个方法的计算也依赖于total()。总数不准,最后一页的页码计算自然也就错了。
说到底,分页的本质无非是“一次 COUNT + 一次 LIMIT”这两条 SQL 的配合。大多数翻车情况,都不是方法不会用,而是没让框架抓住那个原始查询的源头。搞明白了这一点,很多问题也就迎刃而解了。