Una risposta paginata può essere corretta nel momento in cui viene generata e, nonostante ciò, produrre una navigazione incoerente. Se un’applicazione richiede una pagina, l’insieme dei dati cambia e poi viene richiesta la pagina successiva, può ricevere elementi ripetuti o non vederne altri. È un problema frequente negli elenchi di attività, ordini e record che continuano a crescere.
La paginazione con cursore in un’API PHP aiuta a controllare questo spostamento, ma da sola non garantisce una vista congelata dei dati. La decisione importante è definire cosa significa avanzare nell’elenco, quali modifiche possono verificarsi durante la navigazione e quale contratto serve al client.
Perché i risultati cambiano da una pagina all’altra

Supponiamo che una query ordini i record per data decrescente. Il client ottiene i primi 20. Prima che richieda i successivi, vengono inseriti tre record recenti. Se la seconda richiesta usa OFFSET 20, inizia dalla posizione 21 dell’insieme attuale, non dalla posizione 21 che aveva la prima richiesta. Alcuni elementi della prima pagina possono comparire di nuovo.
Possono verificarsi anche omissioni. Se viene eliminato un record che precede l’offset, gli elementi successivi avanzano di una posizione e una riga che il client si aspettava di trovare può rimanere indietro. Inoltre, l’ordine non è necessariamente stabile se più righe hanno la stessa data: senza un criterio aggiuntivo, il database non è tenuto a restituire sempre gli elementi a pari merito nello stesso ordine.
È utile distinguere due obiettivi: evitare salti causati da cambiamenti di posizione e offrire uno snapshot esatto dell’intero insieme. Una paginazione con cursore ben definita aiuta a raggiungere il primo. Il secondo richiede una strategia di consistenza esplicita, che può essere più costosa e dipendere dal database.
Offset o cursore: scegli in base al modello di lettura
La paginazione con offset, normalmente espressa con LIMIT e OFFSET, è semplice e permette di passare direttamente a una pagina nota. Può essere adatta a insiemi piccoli o relativamente statici, interfacce in cui si passa spesso da una pagina all’altra e casi in cui le incoerenze durante la navigazione sono accettabili. Negli insiemi grandi, offset elevati possono richiedere al database di scorrere o scartare molte righe; il costo effettivo dipende dal motore, dagli indici e dalla query.
La paginazione con cursore restituisce un riferimento al punto da cui continuare, per esempio l’ultimo valore di ordinamento e la relativa chiave univoca. La query successiva cerca i record successivi o precedenti a quel punto, invece di saltare un certo numero di righe. È adatta alle navigazioni sequenziali, ai feed e agli elenchi che ricevono inserimenti frequenti. Di contro, non consente naturalmente l’accesso diretto a una pagina arbitraria: il client deve scorrere le pagine o disporre di un’altra strategia.
La scelta non deve necessariamente essere universale per tutta l’API. Si può esporre offset in una query amministrativa con pagine numerate e cursore in un flusso di attività. L’interfaccia deve rispecchiare ciò che il server può garantire, invece di promettere accesso diretto a una pagina arbitraria e stabilità assoluta con un unico meccanismo.
Definisci un ordinamento totale prima di creare il cursore
Il cursore identifica una posizione solo se l’ordinamento è deterministico. Ordinare soltanto per created_at non basta quando due record hanno la stessa data. Aggiungi come criterio di spareggio una colonna univoca e immutabile, per esempio id:
ORDER BY created_at DESC, id DESCIn questo modo, ogni riga occupa una posizione definita nell’ordinamento. Il cursore di continuazione deve contenere entrambi i valori. Per lo stesso ordinamento decrescente, la query successiva cerca le coppie minori dell’ultima coppia restituita:
WHERE created_at < :cursor_date
OR (created_at = :cursor_date AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_sizePer un ordinamento crescente, i confronti si invertono. Con più criteri, le condizioni devono rispettare l’intero ordinamento lessicografico: si confronta prima il primo campo e, in caso di parità, il successivo. Se si combinano direzioni diverse, per esempio data decrescente e identificatore crescente, ogni confronto deve corrispondere alla direzione della rispettiva colonna. Non basta invertire tutti gli operatori insieme.
Anche i valori di ordinamento richiedono regole stabili. Se una colonna può essere null, definisci come ordinare questi valori e codifica tale distinzione nella condizione di continuazione. È preferibile usare criteri immutabili durante la navigazione: se cambia una data che determina la posizione, una riga può spostarsi da un lato all’altro del cursore.
Rendi il cursore opaco, validato e associato alla query
Un cursore può serializzare i valori di ordinamento e codificarli, per esempio con Base64URL. Opaco significa che il consumatore non deve interpretarlo né costruirlo, non che Base64 lo protegga. Se la modifica dei suoi valori può cambiare l’ambito della query, convalida il formato e firma il contenuto con un HMAC o usa un meccanismo equivalente di integrità. Non includere segreti né dati personali non necessari.
Convalida tipi, campi previsti, versione del formato e limiti di dimensione prima di interrogare il database. Usa parametri SQL per i valori. I nomi delle colonne e le direzioni di ordinamento non devono essere accettati direttamente dal cursore o dalla richiesta: devono provenire da una allowlist sul server.
Un cursore con data e identificatore non deve poter essere riutilizzato accidentalmente con filtri diversi, se questo produce una continuazione fuorviante. Puoi includere una rappresentazione canonica dei filtri rilevanti, la direzione dell’ordinamento e, se opportuno, la dimensione della pagina, firmandoli insieme al punto di continuazione. Se non corrispondono alla richiesta attuale, restituisci un errore chiaro invece di continuare silenziosamente con un’altra query. In PHP, centralizza codifica, convalida e firma per non duplicare le regole tra i controller.
Decidi quale consistenza offrire in caso di modifiche concorrenti
Durante la navigazione su dati in continuo aggiornamento, ogni pagina interroga lo stato disponibile in quel momento. Un cursore basato su un ordinamento immutabile evita molti spostamenti causati da inserimenti precedenti al punto raggiunto. Tuttavia, non crea uno snapshot: possono apparire nuove righe dopo il cursore, essere eliminate righe non ancora visitate o cambiare i permessi e i filtri applicabili. Documenta questo comportamento affinché il client non lo confonda con un’esportazione con perimetro definito.
Se il prodotto richiede che tutte le pagine rappresentino un insieme delimitato, una possibilità è fissare una soglia all’inizio della navigazione, per esempio una data o un identificatore massimo, e aggiungerla a ogni query. Quando il criterio scelto lo consente, ciò esclude gli inserimenti successivi alla soglia, ma non conserva le righe eliminate né garantisce uno snapshot perfetto in caso di modifiche. Un’altra possibilità è uno snapshot transazionale; mantenere aperta una transazione tra le richieste comporta spesso implicazioni operative e di risorse, quindi non va considerato come soluzione predefinita.
Il contratto può stabilire limiti chiari: ordinamenti supportati, filtri da mantenere, eventuale scadenza, comportamento in caso di cursore non valido e possibilità che le modifiche concorrenti alterino l’insieme. Non promettere l’assenza assoluta di duplicati o omissioni se la strategia non può garantirla.
Verifica i casi limite e documenta il contratto

I test devono verificare l’intera navigazione, non soltanto la forma di una risposta. Prepara righe con valori di ordinamento ripetuti e verifica che la concatenazione di più pagine produca l’ordine atteso senza duplicati. Includi casi in cui la dimensione della pagina divide un gruppo di elementi a pari merito e convalida sia l’ordinamento crescente sia quello decrescente.
- Inserisci record prima e dopo il cursore tra due richieste e verifica il comportamento concordato.
- Elimina una riga ancora da visitare e modifica una colonna di ordinamento, se il modello lo consente; documentane le conseguenze.
- Modifica un filtro, l’ordinamento o la direzione di navigazione e verifica che un cursore incompatibile venga rifiutato.
- Invia cursori malformati, modificati, troppo grandi o con valori di tipo errato.
- Verifica i limiti della dimensione della pagina e il caso senza risultati, inclusa l’assenza di una pagina successiva.
Per la diagnostica, registra metriche sulla durata delle query, la dimensione della pagina e gli errori di convalida, senza riversare cursori sensibili né dati personali. Se compaiono ripetizioni, verifica prima l’ordinamento totale e la condizione di continuazione. Se il problema è il costo delle query, esamina il piano di esecuzione e gli indici sui campi di ordinamento e sulle condizioni di filtro.
Una paginazione stabile non dipende dal nascondere una stringa in Base64: dipende da un ordinamento deterministico, un confronto coerente, filtri controllati e aspettative esplicite sulle modifiche concorrenti. Con queste decisioni, offset e cursore diventano strumenti selezionabili in base alla navigazione di cui il client ha davvero bisogno.



