$query * @param string $column the timestamp to walk, for a table whose * rows are appended rather than edited * @return CursorPaginator */ public function paginate(Request $request, Builder $query, string $table, string $column = 'updated_at'): CursorPaginator { $since = $request->query('updated_since'); if (is_string($since) && $since !== '') { // Parsed, never passed through as a string. Callers send proper // ISO 8601 ("2026-08-06T05:00:00+02:00"), and the database will // not compare that against a datetime column — MySQL fails to // cast the `T` and the offset and silently matches nothing, so // a polling client would see an empty result forever instead of // an error. Carbon also normalises the offset into the app's // timezone, so a caller in any timezone gets the same rows. $query->where("{$table}.{$column}", '>=', Carbon::parse($since)->timezone(config('app.timezone'))) ->orderBy("{$table}.{$column}") ->orderBy("{$table}.id"); } else { $query->orderByDesc("{$table}.{$column}") ->orderByDesc("{$table}.id"); } return $query->cursorPaginate($this->perPage($request))->withQueryString(); } /** * The cap exists so one caller cannot turn a list endpoint into a * full-table export in a single request. */ public function perPage(Request $request): int { $requested = (int) $request->query('per_page', (string) config('api.pagination.per_page')); $max = (int) config('api.pagination.max_per_page'); return max(1, min($requested, $max)); } /** * Validation rules a controller merges into its own, so `updated_since` * is rejected consistently rather than silently ignored when malformed * — a caller polling with a bad timestamp would otherwise re-read the * whole table on every tick and never notice. * * @return array> */ public function rules(): array { return [ 'updated_since' => ['nullable', 'date'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:'.(int) config('api.pagination.max_per_page')], 'cursor' => ['nullable', 'string'], ]; } }