Suas ideias em realidade digital!
>
Quando uma API começa a retornar muitos registros de uma tabela, carregar tudo de uma vez pode deixar o sistema lento, aumentar o consumo de memória e prejudicar a experiência do usuário. Por isso, em APIs profissionais, é comum usar paginação, busca e filtros para controlar a quantidade de dados retornados.
Em uma API PHP puro com MySQL, você pode implementar esse comportamento de forma simples usando parâmetros na URL,
consultas SQL com LIMIT e OFFSET, além de filtros dinâmicos com WHERE.
Neste artigo, você vai aprender como criar uma API PHP que retorna listas paginadas, permite busca por texto, aplica filtros opcionais e devolve uma resposta JSON organizada para frontend, aplicativo Android, painel administrativo ou DataTables.
Imagine uma tabela com 50 mil clientes, produtos, pedidos ou logs. Se sua API retornar todos esses dados em uma única requisição, o servidor pode demorar para responder, o banco pode ficar sobrecarregado e o frontend pode travar tentando renderizar tudo.
A paginação resolve esse problema dividindo os registros em partes menores. Em vez de retornar todos os dados, a API retorna apenas uma página por vez.
Exemplo:
Esse modelo melhora a velocidade da API, reduz o tráfego de dados e facilita a navegação no frontend.
Uma API bem estruturada pode receber parâmetros pela URL:
/api/usuarios?page=1&limit=10&search=joao&status=ativo
Nesse exemplo:
page indica a página atual;limit indica quantos registros retornar por página;search permite buscar por nome ou e-mail;status filtra usuários ativos ou inativos.Para este artigo, vamos usar uma tabela simples de usuários.
CREATE TABLE usuarios (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ativo',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
Essa tabela possui campos suficientes para demonstrar paginação, busca por texto e filtro por status.
Primeiro, vamos criar uma conexão com o banco usando PDO.
<?php
$pdo = new PDO(
'mysql:host=localhost;dbname=sua_base;charset=utf8mb4',
'usuario',
'senha',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
O uso de PDO com prepared statements ajuda a deixar a consulta mais segura e organizada.
Como estamos criando uma API, todas as respostas devem sair em JSON.
<?php
function jsonResponse(int $statusCode, array $data): void
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
Essa função será usada tanto para sucesso quanto para erro.
Agora vamos capturar os parâmetros enviados na URL.
<?php
$page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 10;
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
$status = isset($_GET['status']) ? trim($_GET['status']) : '';
Esses valores serão usados para montar a consulta SQL.
Antes de usar os valores na consulta, é importante validar os parâmetros.
<?php
if ($page < 1) {
$page = 1;
}
if ($limit < 1) {
$limit = 10;
}
if ($limit > 100) {
$limit = 100;
}
O limite máximo evita que alguém chame a API com algo como limit=100000 e force o servidor a retornar dados demais.
O OFFSET indica a partir de qual registro o MySQL deve começar a retornar os dados.
<?php
$offset = ($page - 1) * $limit;
Exemplo:
page=1 e limit=10, o offset será 0;page=2 e limit=10, o offset será 10;page=3 e limit=10, o offset será 20.Para criar filtros opcionais, podemos montar um array de condições e outro array de parâmetros.
<?php
$where = [];
$params = [];
if ($search !== '') {
$where[] = "(nome LIKE :search OR email LIKE :search)";
$params[':search'] = '%' . $search . '%';
}
if ($status !== '') {
$where[] = "status = :status";
$params[':status'] = $status;
}
$whereSql = '';
if (!empty($where)) {
$whereSql = 'WHERE ' . implode(' AND ', $where);
}
Esse modelo permite adicionar filtros sem montar SQL inseguro por concatenação direta de valores enviados pelo usuário.
$_GET diretamente dentro do SQL.
Use prepared statements para reduzir riscos de SQL Injection.
Agora podemos montar a consulta principal para retornar os dados da página atual.
<?php
$sql = "
SELECT id, nome, email, status, created_at
FROM usuarios
{$whereSql}
ORDER BY id DESC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$usuarios = $stmt->fetchAll();
Aqui usamos bindValue para passar os parâmetros de forma segura.
Para limit e offset, usamos PDO::PARAM_INT.
Além dos dados da página atual, o frontend geralmente precisa saber o total de registros encontrados. Isso permite calcular o total de páginas.
<?php
$sqlTotal = "
SELECT COUNT(*) AS total
FROM usuarios
{$whereSql}
";
$stmtTotal = $pdo->prepare($sqlTotal);
foreach ($params as $key => $value) {
$stmtTotal->bindValue($key, $value);
}
$stmtTotal->execute();
$total = (int) $stmtTotal->fetch()['total'];
O total deve considerar os mesmos filtros usados na consulta principal.
Com o total de registros e o limite por página, podemos calcular o total de páginas.
<?php
$totalPages = (int) ceil($total / $limit);
Esse valor será enviado na resposta JSON.
Uma resposta bem organizada pode ter os dados em data e as informações da paginação em pagination.
<?php
jsonResponse(200, [
'success' => true,
'data' => $usuarios,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'total_pages' => $totalPages
]
]);
Exemplo de retorno:
{
"success": true,
"data": [
{
"id": 15,
"nome": "João Silva",
"email": "joao@exemplo.com",
"status": "ativo",
"created_at": "2026-05-04 10:30:00"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 58,
"total_pages": 6
}
}
Agora veja o exemplo completo reunindo conexão, parâmetros, filtros, consulta e resposta JSON.
<?php
header('Content-Type: application/json; charset=utf-8');
function jsonResponse(int $statusCode, array $data): void
{
http_response_code($statusCode);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
try {
$pdo = new PDO(
'mysql:host=localhost;dbname=sua_base;charset=utf8mb4',
'usuario',
'senha',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
$page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
$limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 10;
$search = isset($_GET['search']) ? trim($_GET['search']) : '';
$status = isset($_GET['status']) ? trim($_GET['status']) : '';
if ($page < 1) {
$page = 1;
}
if ($limit < 1) {
$limit = 10;
}
if ($limit > 100) {
$limit = 100;
}
$offset = ($page - 1) * $limit;
$where = [];
$params = [];
if ($search !== '') {
$where[] = "(nome LIKE :search OR email LIKE :search)";
$params[':search'] = '%' . $search . '%';
}
if ($status !== '') {
$where[] = "status = :status";
$params[':status'] = $status;
}
$whereSql = '';
if (!empty($where)) {
$whereSql = 'WHERE ' . implode(' AND ', $where);
}
$sql = "
SELECT id, nome, email, status, created_at
FROM usuarios
{$whereSql}
ORDER BY id DESC
LIMIT :limit OFFSET :offset
";
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$usuarios = $stmt->fetchAll();
$sqlTotal = "
SELECT COUNT(*) AS total
FROM usuarios
{$whereSql}
";
$stmtTotal = $pdo->prepare($sqlTotal);
foreach ($params as $key => $value) {
$stmtTotal->bindValue($key, $value);
}
$stmtTotal->execute();
$total = (int) $stmtTotal->fetch()['total'];
$totalPages = (int) ceil($total / $limit);
jsonResponse(200, [
'success' => true,
'data' => $usuarios,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'total_pages' => $totalPages
]
]);
} catch (PDOException $e) {
error_log('Erro PDO: ' . $e->getMessage());
jsonResponse(500, [
'success' => false,
'message' => 'Erro interno ao consultar os dados.'
]);
} catch (Throwable $e) {
error_log('Erro inesperado: ' . $e->getMessage());
jsonResponse(500, [
'success' => false,
'message' => 'Erro interno no servidor.'
]);
}
Um frontend pode chamar a API usando fetch.
<script>
async function carregarUsuarios(page = 1) {
const search = document.getElementById('search').value;
const status = document.getElementById('status').value;
const url = `/api/usuarios?page=${page}&limit=10&search=${encodeURIComponent(search)}&status=${encodeURIComponent(status)}`;
const response = await fetch(url);
const result = await response.json();
if (!result.success) {
alert(result.message || 'Erro ao carregar usuários.');
return;
}
console.log(result.data);
console.log(result.pagination);
}
</script>
Com o retorno da paginação, o frontend consegue montar botões de página anterior, próxima página e total de páginas.
Se você usa DataTables com processamento no servidor, pode adaptar a resposta para incluir campos como
recordsTotal, recordsFiltered e data.
<?php
jsonResponse(200, [
'draw' => isset($_GET['draw']) ? (int) $_GET['draw'] : 1,
'recordsTotal' => $total,
'recordsFiltered' => $total,
'data' => $usuarios
]);
Esse formato é útil para tabelas administrativas com muitos registros.
Outro filtro muito comum em APIs é o filtro por período.
<?php
$dataInicio = isset($_GET['data_inicio']) ? trim($_GET['data_inicio']) : '';
$dataFim = isset($_GET['data_fim']) ? trim($_GET['data_fim']) : '';
if ($dataInicio !== '') {
$where[] = "created_at >= :data_inicio";
$params[':data_inicio'] = $dataInicio . ' 00:00:00';
}
if ($dataFim !== '') {
$where[] = "created_at <= :data_fim";
$params[':data_fim'] = $dataFim . ' 23:59:59';
}
Nesse caso, é importante validar se as datas estão no formato esperado antes de usar na consulta.
<?php
function dataValida(string $data): bool
{
$dt = DateTime::createFromFormat('Y-m-d', $data);
return $dt && $dt->format('Y-m-d') === $data;
}
Exemplo de uso:
<?php
if ($dataInicio !== '' && !dataValida($dataInicio)) {
jsonResponse(422, [
'success' => false,
'message' => 'Data inicial inválida.',
'errors' => [
'data_inicio' => 'Use o formato YYYY-MM-DD.'
]
]);
}
Muitas APIs permitem ordenar resultados por nome, data ou ID.
Porém, não é seguro colocar diretamente o valor de order_by dentro do SQL.
O ideal é criar uma lista de campos permitidos.
<?php
$orderBy = $_GET['order_by'] ?? 'id';
$orderDir = strtolower($_GET['order_dir'] ?? 'desc');
$allowedOrderBy = ['id', 'nome', 'email', 'created_at'];
$allowedOrderDir = ['asc', 'desc'];
if (!in_array($orderBy, $allowedOrderBy, true)) {
$orderBy = 'id';
}
if (!in_array($orderDir, $allowedOrderDir, true)) {
$orderDir = 'desc';
}
$sql = "
SELECT id, nome, email, status, created_at
FROM usuarios
{$whereSql}
ORDER BY {$orderBy} {$orderDir}
LIMIT :limit OFFSET :offset
";
Nesse caso, como order_by e order_dir não podem ser enviados como parâmetros preparados,
usamos uma lista branca de valores permitidos.
10, 20 ou 50 registros por página;100 registros;LIMIT e OFFSET corretamente;
Se sua API filtra frequentemente por campos como status, created_at ou email,
criar índices pode melhorar bastante a performance.
CREATE INDEX idx_usuarios_status ON usuarios(status);
CREATE INDEX idx_usuarios_created_at ON usuarios(created_at);
CREATE INDEX idx_usuarios_email ON usuarios(email);
Índices ajudam o banco a localizar dados com mais eficiência, principalmente em tabelas grandes.
Esse é um dos erros mais comuns. Mesmo que funcione no começo, quando a base crescer, a API pode ficar lenta.
Se você permite qualquer valor em limit, alguém pode forçar a API a retornar milhares de registros.
Valores enviados por $_GET devem ser tratados com cuidado.
Use prepared statements sempre que possível.
Sem o total, o frontend não consegue montar uma paginação completa.
Em tabelas grandes, filtros sem índice podem deixar a consulta lenta.
page e limit?offset está sendo calculado corretamente?data e pagination?Criar paginação, busca e filtros em uma API PHP com MySQL é uma das melhores formas de melhorar a performance e a experiência do usuário. Em vez de retornar todos os registros de uma vez, sua API passa a entregar apenas os dados necessários para cada tela.
Com LIMIT, OFFSET, filtros dinâmicos, prepared statements e uma resposta JSON padronizada,
você consegue criar endpoints mais rápidos, seguros e fáceis de integrar com frontend, aplicativos e sistemas externos.
Esse tipo de estrutura é essencial para qualquer API PHP que trabalha com listagens, painéis administrativos, relatórios, buscas de clientes, produtos, pedidos ou registros de log.
Porque isso pode deixar a API lenta, aumentar o consumo de memória e travar o frontend em tabelas grandes. A paginação retorna apenas uma parte dos dados por requisição.
LIMIT define quantos registros serão retornados.
OFFSET define a partir de qual posição o banco deve começar a buscar.
Depende do sistema, mas valores como 10, 20, 50 ou 100 são comuns.
Também é recomendado definir um limite máximo para evitar abusos.
Você pode usar LIKE no SQL com prepared statements, procurando o termo em campos como nome e email.
Sim. O total ajuda o frontend a calcular quantas páginas existem e montar os controles de paginação.
Sim. Ao retornar menos dados por requisição, a API reduz carga no banco, no servidor e no frontend. Em tabelas grandes, também é importante criar índices nos campos mais usados em filtros e ordenação.
Porque isso pode deixar a API lenta, aumentar o consumo de memória e travar o frontend em tabelas grandes. A paginação retorna apenas uma parte dos dados por requisição.
LIMIT define quantos registros serão retornados.
OFFSET define a partir de qual posição o banco deve começar a buscar.
Depende do sistema, mas valores como 10, 20, 50 ou 100 são comuns.
Também é recomendado definir um limite máximo para evitar abusos.
Você pode usar LIKE no SQL com prepared statements, procurando o termo em campos como nome e email.
Sim. O total ajuda o frontend a calcular quantas páginas existem e montar os controles de paginação.
Sim. Ao retornar menos dados por requisição, a API reduz carga no banco, no servidor e no frontend. Em tabelas grandes, também é importante criar índices nos campos mais usados em filtros e ordenação.