Backend-разработка

Laravel и REST API: продвинутая система фильтрации и сортировки с PostgreSQL

Ruslan Ismailov Опубликовано 12 мин чтения
L

Введение: почему стандартных средств 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. Подробнее обо мне →