Una respuesta paginada puede ser correcta en el momento en que se ejecuta y, aun así, producir un recorrido inconsistente. Si una aplicación solicita una página, cambia el conjunto de datos y luego solicita la siguiente, puede recibir elementos repetidos o dejar otros sin ver. Es un problema habitual en listados de actividad, pedidos y registros que siguen creciendo.
La paginación por cursor en una API PHP ayuda a controlar ese desplazamiento, pero no garantiza por sí sola una vista congelada de los datos. La decisión importante es definir qué significa avanzar por la lista, qué cambios pueden ocurrir durante el recorrido y qué contrato necesita el cliente.
Por qué cambian los resultados entre páginas

Supongamos que una consulta ordena los registros por fecha descendente. El cliente obtiene los primeros 20. Antes de pedir los siguientes, se insertan tres registros recientes. Si la segunda petición usa OFFSET 20, empieza en la posición 21 del conjunto actual, no en la posición 21 que tenía la primera petición. Algunos elementos de la primera página pueden aparecer otra vez.
También puede haber omisiones. Si se elimina un registro situado antes del desplazamiento, los elementos posteriores avanzan una posición y una fila que el cliente esperaba encontrar puede quedar atrás. El orden tampoco es necesariamente estable si varias filas comparten la misma fecha: sin un criterio adicional, la base de datos no tiene por qué devolver esos empates siempre en el mismo orden.
Conviene distinguir dos objetivos: evitar saltos causados por cambios de posición y ofrecer una instantánea exacta de todo el conjunto. Una paginación por cursor bien definida ayuda con el primero. El segundo requiere una estrategia explícita de consistencia, que puede ser más costosa y depender de la base de datos.
Offset o cursor: elige según el patrón de lectura
La paginación por desplazamiento, normalmente expresada con LIMIT y OFFSET, es sencilla y permite ir directamente a una página conocida. Puede ser adecuada para conjuntos pequeños o relativamente estáticos, interfaces con saltos frecuentes entre páginas y casos donde las inconsistencias durante la navegación son aceptables. En conjuntos grandes, los desplazamientos elevados pueden exigir a la base de datos recorrer o descartar muchas filas; el coste real depende del motor, los índices y la consulta.
La paginación por cursor devuelve una referencia al punto desde el que continuar, por ejemplo, el último valor de orden y su clave única. La siguiente consulta busca registros posteriores o anteriores a ese punto, en lugar de saltar una cantidad de filas. Encaja con recorridos secuenciales, feeds y listados que reciben inserciones frecuentes. A cambio, no ofrece de forma natural un salto a una página arbitraria: el cliente necesita recorrer páginas o disponer de otra estrategia.
La elección no tiene por qué ser universal para toda la API. Puede exponerse offset en una consulta administrativa con páginas numeradas y cursor en un flujo de actividad. La interfaz debe reflejar lo que el servidor puede garantizar, en vez de prometer navegación aleatoria y estabilidad absoluta con una única mecánica.
Define un orden total antes de crear el cursor
El cursor sólo identifica una posición si el orden es determinista. Ordenar únicamente por created_at no basta cuando dos registros tienen la misma fecha. Añade una columna única e inmutable como desempate, por ejemplo, id:
ORDER BY created_at DESC, id DESCAsí, cada fila ocupa una posición definida dentro del orden. El cursor de continuación debe contener ambos valores. Para el mismo sentido descendente, la consulta siguiente busca los pares menores que el último par entregado:
WHERE created_at < :cursor_date
OR (created_at = :cursor_date AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_sizePara un orden ascendente, las comparaciones se invierten. Con varios criterios, las condiciones deben respetar el orden lexicográfico completo: se compara primero el primer campo y, si empata, el siguiente. Si se mezclan direcciones —por ejemplo, fecha descendente e identificador ascendente—, cada comparación debe corresponder a la dirección de su columna. No basta con invertir todos los operadores a la vez.
Los valores de orden también necesitan reglas estables. Si una columna puede ser nula, define cómo se ordenan esos valores y codifica esa distinción en la condición de continuación. Es preferible usar criterios inmutables durante el recorrido: si se modifica una fecha que determina la posición, una fila puede moverse de un lado del cursor al otro.
Haz el cursor opaco, validado y ligado a la consulta
Un cursor puede serializar los valores de orden y codificarlos, por ejemplo, con Base64URL. Que sea opaco significa que el consumidor no necesita interpretarlo ni construirlo, no que Base64 lo proteja. Si alterar sus valores pudiera cambiar el alcance de la consulta, valida el formato y firma el contenido con un HMAC o utiliza un mecanismo equivalente de integridad. No incluyas secretos ni datos personales innecesarios.
Valida tipos, campos esperados, versión del formato y límites de tamaño antes de consultar la base de datos. Usa parámetros SQL para los valores. Los nombres de columnas y las direcciones de orden no se deben aceptar directamente desde el cursor o la petición: deben salir de una lista permitida en el servidor.
Un cursor de fecha e identificador no debe poder reutilizarse accidentalmente con filtros distintos si eso produce una continuación engañosa. Puedes incluir una representación canónica de los filtros relevantes, el sentido de orden y, si procede, el tamaño de página, y firmarlos junto al punto de continuación. Si no coinciden con la petición actual, responde con un error claro en lugar de continuar silenciosamente con otra consulta. En PHP, centraliza la codificación, validación y firma para no duplicar reglas entre controladores.
Decide qué consistencia ofrecer ante cambios concurrentes
En un recorrido sobre datos vivos, cada página consulta el estado disponible en ese momento. Un cursor basado en un orden inmutable evita muchos desplazamientos provocados por inserciones anteriores al punto alcanzado. Pero no crea una instantánea: pueden aparecer filas nuevas después del cursor, borrarse filas aún no visitadas o cambiar los permisos y filtros aplicables. Documenta ese comportamiento para que el cliente no lo confunda con una exportación cerrada.
Si el producto necesita que todas las páginas representen un conjunto delimitado, una opción es fijar un corte, como una fecha o un identificador máximo al iniciar el recorrido, y añadirlo a cada consulta. Esto excluye inserciones posteriores al corte cuando el criterio elegido lo permite, pero no conserva filas borradas ni garantiza una instantánea perfecta frente a modificaciones. Otra posibilidad es una instantánea transaccional; mantener una transacción abierta entre peticiones suele tener implicaciones operativas y de recursos, por lo que no debe asumirse como solución por defecto.
El contrato puede expresar límites claros: orden soportado, filtros que deben mantenerse, caducidad si existe, comportamiento ante un cursor inválido y si los cambios concurrentes pueden alterar el conjunto. No prometas ausencia absoluta de duplicados u omisiones si la estrategia no puede garantizarla.
Prueba las fronteras y documenta el contrato

Las pruebas deben verificar el recorrido completo, no sólo la forma de una respuesta. Prepara filas con valores de orden repetidos y comprueba que varias páginas concatenadas producen el orden esperado sin duplicados. Incluye casos en los que el tamaño de página divide un grupo de empates y valida tanto el sentido ascendente como el descendente.
- Inserta registros antes y después del cursor entre dos solicitudes y comprueba el comportamiento acordado.
- Elimina una fila pendiente y modifica una columna de orden, si el modelo lo permite; documenta las consecuencias.
- Cambia un filtro, el orden o el sentido de recorrido y comprueba que un cursor incompatible se rechaza.
- Envía cursores mal formados, alterados, demasiado grandes o con valores de tipos incorrectos.
- Verifica los límites del tamaño de página y el caso sin resultados, incluida la ausencia de una página siguiente.
Para diagnósticos, registra métricas de duración de consulta, tamaño de página y errores de validación sin volcar cursores sensibles ni datos personales. Si aparecen repeticiones, revisa primero el orden total y la condición de continuación. Si el problema es el coste de las consultas, inspecciona el plan y los índices sobre los campos de orden y las condiciones de filtro.
Una paginación estable no depende de ocultar una cadena en Base64: depende de un orden determinista, una comparación coherente, filtros controlados y expectativas explícitas sobre los cambios concurrentes. Con esas decisiones, offset y cursor se convierten en herramientas elegibles según el recorrido real que necesita el cliente.



