Laravel и REST API: продвинутая система фильтрации и сортировки с PostgreSQL
Введение: почему стандартных средств Laravel недостаточно
Laravel предоставляет удобный Eloquent ORM и встроенную пагинацию, но как только клиент начинает требовать фильтрацию по десяти полям, сортировку по вычисляемым значениям и курсорную пагинацию для бесконечной прокрутки — контроллеры превращаются в кашу из if ($request->has(...)). Команды без системного подхода получают: дублирование логики фильтрации в разных контроллерах, SQL-инъекции через динамическую сортировку, N+1 запросы и полное отсутствие документации для фронтенд-команды. В 2026 году, когда REST API обслуживает одновременно веб-клиент, мобильное приложение и внешних интеграторов, это недопустимо.
В этой статье мы построим полноценную систему фильтрации на Laravel с PostgreSQL: от проектирования API-контракта до оптимизации запросов и тестирования.
Проектирование API-контракта: query-параметры и соглашения
Перед написанием кода зафиксируйте контракт. Хаотичные параметры вроде filterByStatus, status_filter и status в разных эндпоинтах — первый признак отсутствия системы.
Рекомендуемые соглашения
- Фильтры:
filter[field]=valueилиfilter[field][operator]=value - Сортировка:
sort=fieldдля ASC,sort=-fieldдля DESC (минус-префикс как в JSON:API) - Пагинация:
page[size]=25&page[cursor]=eyJpZCI6MTAwfQдля курсорной илиpage[number]=2&page[size]=25для offset - Полнотекстовый поиск:
search=query
Примеры реальных URL:
GET /api/v1/products?filter[status]=active&filter[price][gte]=100&sort=-created_at&page[size]=20
GET /api/v1/products?search=wireless+headphones&filter[category_id]=5&sort=price
GET /api/v1/orders?filter[created_at][gte]=2026-01-01&filter[user_id]=42&sort=-total
Такой формат самодокументируем, легко парсится на клиенте и масштабируется без изменения архитектуры.
Паттерн Filter/Scope в Laravel: переиспользуемые классы фильтрации
Ключевая идея — вынести логику фильтрации из контроллера в отдельный класс, применяемый через Eloquent local scope. Контроллер остаётся тонким, логика тестируется изолированно.
Базовый класс фильтра
<?php
namespace App\Http\Filters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class AbstractFilter
{
protected Request $request;
protected Builder $builder;
// Карта: имя параметра => метод-обработчик
protected array $filters = [];
public function __construct(Request $request)
{
$this->request = $request;
}
public function apply(Builder $builder): Builder
{
$this->builder = $builder;
foreach ($this->filters as $param => $method) {
$value = data_get($this->request->input('filter'), $param);
if ($value !== null && $value !== '') {
$this->$method($value);
}
}
return $this->builder;
}
}
Конкретный фильтр для модели Product
<?php
namespace App\Http\Filters;
use Illuminate\Database\Eloquent\Builder;
class ProductFilter extends AbstractFilter
{
protected array $filters = [
'status' => 'byStatus',
'category_id' => 'byCategory',
'price' => 'byPrice',
'created_at' => 'byCreatedAt',
];
protected function byStatus(string $value): void
{
$this->builder->where('status', $value);
}
protected function byCategory(int|string $value): void
{
$this->builder->where('category_id', (int) $value);
}
// Поддержка операторов: filter[price][gte]=100&filter[price][lte]=500
protected function byPrice(array|string $value): void
{
if (is_array($value)) {
$operators = ['gte' => '>=', 'lte' => '<=', 'gt' => '>', 'lt' => '<'];
foreach ($operators as $key => $op) {
if (isset($value[$key])) {
$this->builder->where('price', $op, (float) $value[$key]);
}
}
} else {
$this->builder->where('price', (float) $value);
}
}
protected function byCreatedAt(array|string $value): void
{
if (is_array($value)) {
if (isset($value['gte'])) {
$this->builder->whereDate('created_at', '>=', $value['gte']);
}
if (isset($value['lte'])) {
$this->builder->whereDate('created_at', '<=', $value['lte']);
}
}
}
}
Trait для модели и scope
<?php
namespace App\Traits;
use App\Http\Filters\AbstractFilter;
use Illuminate\Database\Eloquent\Builder;
trait Filterable
{
public function scopeFilter(Builder $query, AbstractFilter $filter): Builder
{
return $filter->apply($query);
}
}
// В модели Product:
// use Filterable;
// В контроллере:
// Product::filter($filter)->paginate();
Контроллер становится лаконичным:
<?php
public function index(Request $request, ProductFilter $filter)
{
$products = Product::filter($filter)
->applySorting($request)
->cursorPaginate($request->input('page.size', 25));
return ProductResource::collection($products);
}
Динамическая сортировка: безопасность прежде всего
Прямая подстановка $request->input('sort') в orderBy() — SQL-инъекция. Whitelist-подход обязателен.
<?php
namespace App\Http\Sorts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class ProductSorter
{
// Разрешённые поля и их алиасы в БД
protected array $allowedSorts = [
'price' => 'price',
'created_at' => 'created_at',
'name' => 'name',
'rating' => 'average_rating', // алиас для вычисляемого поля
];
public function apply(Builder $query, Request $request): Builder
{
$sortParam = $request->input('sort', '-created_at');
$direction = str_starts_with($sortParam, '-') ? 'desc' : 'asc';
$field = ltrim($sortParam, '-');
if (!array_key_exists($field, $this->allowedSorts)) {
// Игнорируем невалидную сортировку, применяем дефолтную
return $query->orderBy('created_at', 'desc');
}
return $query->orderBy($this->allowedSorts[$field], $direction)
->orderBy('id', $direction); // тай-брейкер для стабильной сортировки
}
}
Тай-брейкер по id критически важен для корректной курсорной пагинации — без него порядок записей с одинаковым значением сортируемого поля непредсказуем.
Курсорная пагинация vs offset-пагинация
Offset-пагинация (LIMIT 25 OFFSET 500) проста, но при больших объёмах PostgreSQL вынужден отсчитать и отбросить первые 500 строк. При миллионах записей это деградирует в full scan.
Курсорная пагинация использует условие WHERE по значению последней записи: WHERE (created_at, id) < ('2026-01-15', 1000). PostgreSQL использует индекс напрямую — O(log n) вместо O(n).
Laravel 8+ имеет встроенный cursorPaginate(), но он поддерживает сортировку только по одному полю. Для составной сортировки реализуем вручную:
<?php
namespace App\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class CursorPaginator
{
public function paginate(Builder $query, int $perPage, ?string $cursor): array
{
if ($cursor) {
$decoded = json_decode(base64_decode($cursor), true);
// Составное условие: (sort_field, id) < (value, id)
$query->where(function ($q) use ($decoded) {
$q->where('created_at', '<', $decoded['created_at'])
->orWhere(function ($q2) use ($decoded) {
$q2->where('created_at', $decoded['created_at'])
->where('id', '<', $decoded['id']);
});
});
}
$items = $query->limit($perPage + 1)->get();
$hasMore = $items->count() > $perPage;
$items = $items->take($perPage);
$nextCursor = null;
if ($hasMore && $last = $items->last()) {
$nextCursor = base64_encode(json_encode([
'created_at' => $last->created_at->toISOString(),
'id' => $last->id,
]));
}
return ['data' => $items, 'next_cursor' => $nextCursor, 'has_more' => $hasMore];
}
}
Когда использовать offset: административные панели, где нужен переход на конкретную страницу. Курсорная — для бесконечного скролла и высоконагруженных публичных API.
Использование возможностей PostgreSQL
Полнотекстовый поиск через tsvector
Вместо LIKE '%query%' (seq scan) используйте встроенный полнотекстовый поиск PostgreSQL:
-- Миграция: добавляем столбец и индекс
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('russian', coalesce(name, '') || ' ' || coalesce(description, ''))
) STORED;
CREATE INDEX products_search_vector_idx ON products USING GIN(search_vector);
<?php
// В фильтре или scope:
protected function search(string $query): void
{
$this->builder->whereRaw(
"search_vector @@ plainto_tsquery('russian', ?)",
[$query]
)->orderByRaw(
"ts_rank(search_vector, plainto_tsquery('russian', ?)) DESC",
[$query]
);
}
Фильтрация по JSONB-полям
<?php
// Фильтрация по вложенному JSON: filter[attributes][color]=red
protected function byAttributes(array $value): void
{
foreach ($value as $key => $val) {
$this->builder->whereRaw(
"attributes->>? = ?",
[$key, $val]
);
}
}
// Индекс для JSONB:
// CREATE INDEX products_attributes_gin ON products USING GIN(attributes);
Оптимизация запросов: EXPLAIN ANALYZE и индексы
Каждый популярный фильтр должен быть покрыт индексом. Анализируйте планы запросов:
-- Запускайте в psql или через Laravel DB::select()
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM products
WHERE status = 'active'
AND category_id = 5
AND price BETWEEN 100 AND 500
ORDER BY created_at DESC, id DESC
LIMIT 25;
Составной индекс под типовую комбинацию фильтров:
-- Порядок колонок: сначала equality-фильтры, потом range, потом sort
CREATE INDEX products_listing_idx
ON products (status, category_id, price, created_at DESC, id DESC)
WHERE status = 'active'; -- partial index экономит место
В Laravel выбирайте только нужные поля:
<?php
Product::filter($filter)
->select(['id', 'name', 'price', 'status', 'created_at', 'thumbnail_url'])
->with(['category:id,name']) // eager loading только нужных полей
->cursorPaginate(25);
Кэширование результатов фильтрации
Кэширование API с вариативными параметрами — нетривиальная задача. Не кэшируйте всё подряд: для уникальных комбинаций фильтров кэш бесполезен и засорит Redis.
Стратегия: кэш только для «горячих» запросов
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\Request;
class FilterCacheService
{
// Кэшируем только если запрос без пользовательских фильтров
// (каталожные страницы, главная) — TTL 5 минут
public function remember(Request $request, callable $callback): mixed
{
$filterParams = $request->input('filter', []);
$hasUserSpecificFilter = isset($filterParams['user_id']);
if ($hasUserSpecificFilter || count($filterParams) > 3) {
return $callback(); // без кэша
}
$cacheKey = 'products:' . md5($request->getQueryString());
return Cache::tags(['products'])->remember($cacheKey, 300, $callback);
}
// Инвалидация при изменении данных:
public static function flush(): void
{
Cache::tags(['products'])->flush();
}
}
Для Redis используйте тегированный кэш (Cache::tags()) — это позволяет сбрасывать все записи по тегу products при любом изменении каталога через Observer.
Документирование API фильтрации: OpenAPI/Swagger
Без документации фронтенд-команда будет угадывать параметры. Используйте пакет darkaonline/l5-swagger с PHP-аннотациями:
<?php
/**
* @OA\Get(
* path="/api/v1/products",
* summary="Список продуктов с фильтрацией",
* tags={"Products"},
* @OA\Parameter(
* name="filter[status]",
* in="query",
* description="Статус продукта: active, inactive, draft",
* @OA\Schema(type="string", enum={"active", "inactive", "draft"})
* ),
* @OA\Parameter(
* name="filter[price][gte]",
* in="query",
* description="Минимальная цена",
* @OA\Schema(type="number")
* ),
* @OA\Parameter(
* name="sort",
* in="query",
* description="Поле сортировки. Префикс '-' для DESC. Доступно: price, created_at, name, rating",
* @OA\Schema(type="string", example="-created_at")
* ),
* @OA\Parameter(
* name="page[cursor]",
* in="query",
* description="Курсор для следующей страницы (из поля next_cursor предыдущего ответа)",
* @OA\Schema(type="string")
* ),
* @OA\Response(response=200, description="OK")
* )
*/
public function index(Request $request, ProductFilter $filter): JsonResponse
{
// ...
}
Тестирование: юнит и feature-тесты
Тестируйте каждый фильтр изолированно (юнит) и поведение эндпоинта целиком (feature).
<?php
namespace Tests\Unit\Filters;
use App\Http\Filters\ProductFilter;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
class ProductFilterTest extends TestCase
{
use RefreshDatabase;
public function test_filters_by_status(): void
{
Product::factory()->count(3)->create(['status' => 'active']);
Product::factory()->count(2)->create(['status' => 'inactive']);
$request = Request::create('/', 'GET', ['filter' => ['status' => 'active']]);
$filter = new ProductFilter($request);
$result = Product::filter($filter)->get();
$this->assertCount(3, $result);
$this->assertTrue($result->every(fn($p) => $p->status === 'active'));
}
public function test_filters_by_price_range(): void
{
Product::factory()->create(['price' => 50]);
Product::factory()->create(['price' => 200]);
Product::factory()->create(['price' => 600]);
$request = Request::create('/', 'GET', [
'filter' => ['price' => ['gte' => '100', 'lte' => '500']]
]);
$filter = new ProductFilter($request);
$result = Product::filter($filter)->get();
$this->assertCount(1, $result);
$this->assertEquals(200, $result->first()->price);
}
public function test_invalid_sort_field_falls_back_to_default(): void
{
$request = Request::create('/', 'GET', ['sort' => 'malicious_field; DROP TABLE products;--']);
// Убеждаемся, что запрос выполняется без исключения
$response = $this->getJson('/api/v1/products?sort=malicious_field');
$response->assertOk();
}
}
Итог: чеклист качественного API фильтрации
- ✅ Единый API-контракт зафиксирован и задокументирован в OpenAPI
- ✅ Логика фильтрации вынесена в отдельные Filter-классы, не в контроллер
- ✅ Whitelist разрешённых полей сортировки — защита от SQL-инъекций
- ✅ Курсорная пагинация для публичных эндпоинтов с большим объёмом данных
- ✅ Полнотекстовый поиск через tsvector вместо LIKE
- ✅ Составные индексы PostgreSQL под реальные комбинации фильтров
- ✅ EXPLAIN ANALYZE проверен для топ-5 запросов
- ✅ SELECT только нужных полей, eager loading без N+1
- ✅ Тегированный кэш с инвалидацией по событиям модели
- ✅ Юнит-тесты для каждого фильтра, feature-тесты для эндпоинтов
Системный подход к фильтрации — это не преждевременная оптимизация. Это архитектурное решение, которое определяет, сможет ли ваш REST API на Laravel обслуживать растущую нагрузку без рефакторинга через полгода. PostgreSQL даёт мощный инструментарий: GIN-индексы, tsvector, JSONB — используйте их осознанно, опираясь на реальные планы запросов, а не интуицию.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →