0% прочитано

Laravel Admin Sorting: чому сортування таблиці дало 500 і чому sort fields мають бути allowlist

Реальний кейс ICanUp: Admin Posts відправляв createdAt і publishedAt, backend очікував інші sort keys, а generic sorting не мав достатньо жорсткої межі. Розбираю client key, SQL column, allowlist, direction validation, reorder, localized fields і deterministic tie-breaker.

25 вересня 2026 р. 10 хв читанняЛаравель

У таблиці Posts в ICanUp сортування виглядало звичайною UI-функцією: натиснути на колонку, передати sort у backend і змінити ORDER BY.

Але саме колонки дат показали, що між назвою колонки у frontend і реальною колонкою SQL має існувати чіткий контракт. Frontend працював із createdAt та publishedAt, а backend sorting map використовував created_at та published_at.

Результатом був не просто неправильний порядок. Сортування могло завершитися серверною помилкою, а сама generic sorting логіка дозволяла зовнішньому sort key підходити надто близько до SQL.

Frontend надсилає ID колонки, а не назву поля бази

У TanStack Table sortable column має свій ID. Для Posts це, зокрема, publishedAt і createdAt.

Коли користувач змінює sorting, frontend формує значення на кшталт publishedAt:asc. Backend отримує саме цей public UI contract. Він не отримує posts.published_at і не повинен вимагати від браузера знати структуру бази.

JavaScript - The Admin table sends its own sort IDs
const initialSortParts =    typeof props.filters?.sort === "string"        ? props.filters.sort.split(":")        : []; const sortableIds = [    "id",    "title",    "status",    "slug",    "publishedAt",    "createdAt",]; const sorting = ref(    initialSortParts.length === 2
JavaScript - Date columns use camelCase accessor IDs
col.accessor("publishedAt", {    header: () => t("admin.posts.published_at"),    cell: (info) =>        formatAdminDateTime(            info.getValue(),            locale.value,        ) || "-",    enableSorting: true,}); col.accessor("createdAt", {    header: () => t("admin.common.created_at"),    cell: (info) =>        formatAdminDateTime(            info.getValue(),            locale.value,

Проблема була в контракті createdAt проти created_at

До виправлення backend map для дат мав snake_case keys. Тобто frontend надсилав createdAt, а allowlist знав created_at. Те саме відбувалося з publishedAt.

Це хороший приклад того, чому назва в UI, назва JSON field і назва SQL column - не одне й те саме.

PHP - The old date sort keys did not match the frontend
protected array $baseOrderColumns = [    'id' => 'posts.id',    'title' => 'pt.value',    'slug' => 'posts.slug',    'status' => 'posts.status',    'created_at' => 'posts.created_at',    'published_at' => 'posts.published_at',];
Я не покладаюсь на конкретний текст SQL exception у цій історії, бо він не зберігся разом із diff. Підтверджений root cause - невідповідність sort keys і небезпечна поведінка generic sorting для невідомого input.

Allowlist є перекладачем між UI та SQL

Після виправлення mapping описує рівно ті keys, які має право надсилати Admin UI.

Client sort key SQL expression
id posts.id
title pt.value
slug posts.slug
status posts.status
createdAt posts.created_at
publishedAt posts.published_at

Тепер request визначає лише логічний ключ. Реальне SQL field або expression завжди вибирає сервер.

PHP - The server-controlled sort allowlist
protected array $baseOrderColumns = [    'id' => 'posts.id',    'title' => 'pt.value',    'slug' => 'posts.slug',    'status' => 'posts.status',    'createdAt' => 'posts.created_at',    'publishedAt' => 'posts.published_at',];

Перевіряти потрібно і key, і direction

Allowlist колонок вирішує лише половину задачі. Частина після двокрапки теж приходить із request.

Тому direction допускає лише два значення: asc або desc. Невідомий key, неправильний direction, array замість string або malformed value не повинні доходити до SQL.

У такому випадку таблиця просто повертається до передбачуваного default order.

PHP - Safe sorting in PostDataTable
protected function order(): void{    $sort = $this->request->input('sort');     [$key, $direction] = array_pad(        explode(            ':',            is_string($sort) ? $sort : '',            2,        ),        2,        null,    );     $column =        $this->baseOrderColumns[$key]

Request може вибрати один із дозволених способів сортування. Він не повинен конструювати SQL sorting expression.

Навіщо тут reorder

builderInit() одразу задає default ORDER BY posts.id DESC. Це добре для звичайного відкриття списку, але створює пастку для server-side sorting.

Якщо потім просто додати orderBy(posts.published_at), початкове сортування не зникає. posts.id DESC залишається першим, а дата стає лише другим критерієм.

reorder() очищає попередній ORDER BY. Після цього можна застосувати саме той порядок, який попросив користувач.

PHP - The table starts with a default order
protected function builderInit(): void{    $this->builder = Post::query()        ->select($this->columns)        ->leftJoin(            'post_translations as pt',            function ($join): void {                $join                    ->on(                        'pt.post_id',                        '=',                        'posts.id',                    )                    ->where(                        'pt.name',                        '=',

Title показує, чому client field не може дорівнювати database field

З датами різниця виглядає просто: publishedAt мапиться на posts.published_at. Але title показує справжню цінність allowlist.

У таблиці Posts title не є колонкою posts.title. Локалізоване значення приходить із post_translations, який приєднаний як pt. Тому public sort key title мапиться на pt.value.

UI не повинен знати ні назву таблиці translations, ні alias join, ні спосіб зберігання локалізації.

Дати потрібно сортувати як raw datetime

Admin показує дату у локалізованому форматі через formatAdminDateTime(). Але presentation value не бере участі у sorting.

Backend сортує безпосередньо posts.created_at або posts.published_at. Це важливо, бо форматований текст залежить від locale і призначений для людини, а не для визначення хронологічного порядку.

PHP - The regression test sorts raw publication datetime
it(    'sorts posts by raw publication datetime',    function (string $direction): void {        $later = Post::factory()->create([            'published_at' =>                '2026-09-30 17:45:00',        ]);         $earlier = Post::factory()->create([            'published_at' =>                '2026-09-30 08:00:00',        ]);         [$first, $second] =            $direction === 'asc'                ? [$earlier, $later]

Однакові значення потребують tie-breaker

Два Posts можуть мати однаковий published_at. Якщо SQL знає тільки цей критерій, порядок між ними не є частиною нашого контракту.

Тому після основного поля додається posts.id DESC. Це робить результат стабільним і передбачуваним навіть для однакових timestamps.

PHP - A regression test for equal publication timestamps
it(    'uses newest ID to break publication datetime ties',    function (): void {        $older = Post::factory()->create([            'published_at' =>                '2026-09-30 12:00:00',        ]);         $newer = Post::factory()->create([            'published_at' =>                '2026-09-30 12:00:00',        ]);         $this->get(            route(                'admin.posts.index',

Найважливіший тест передає в sort те, чого там не повинно бути

Happy path із publishedAt:asc перевіряє функціональність. Але безпеку й надійність контракту краще видно на неправильних значеннях.

У тестах я передаю невідому колонку, реальне ім’я database column, SQL-looking expression, неправильний direction, кілька direction одразу та array замість string.

У кожному випадку endpoint має залишитися доступним, повернути default sorting і не використати request input як SQL expression.

PHP - Unsupported sort input never becomes SQL
it(    'falls back to newest post for unsupported sorting without using request input in SQL',    function (string|array $sort): void {        $older =            Post::factory()->create();         $newer =            Post::factory()->create();         $queries = [];         DB::listen(            static function (                QueryExecuted $query,            ) use (&$queries): void {                $queries[] =
Allowlist тут є одночасно architecture contract і security boundary. Навіть справжня database column name posts.created_at не приймається як client key. Дозволене лише публічне ім’я createdAt, яке сервер сам мапить на SQL.

Sort також потрібно зберігати між сторінками й фільтрами

Server-side sorting не закінчується на правильному ORDER BY. Якщо після search, status filter або переходу на іншу сторінку параметр sort загубиться, користувач отримає інший порядок без явної причини.

Тому regressions перевіряють і сам SQL order, і те, що filters.sort повертається в Inertia payload та зберігається під час pagination.

PHP - Sorting remains part of the list state
$params = [    'sort' =>        'slug:desc',     'search' =>        'sorting',     'status' =>        PostStatusEnum::PUBLISHED->value,     'per_page' =>        1,]; $this->get(    route(

Що я забрала з цього бага

Спочатку це виглядало як невеликий баг двох колонок дат. Насправді він показав слабке місце в контракті всієї Admin DataTable.

Frontend sort ID не є database column. Видиме поле може мапитися на snake_case column, join alias, aggregate або інше server-side expression. Саме backend має володіти цим mapping.

Тому після цього кейсу для мене хороший sorting contract має п’ять властивостей:

  1. кожен client key є в явному allowlist;
  2. direction окремо обмежений asc і desc;
  3. невідомий input не потрапляє в SQL;
  4. попередній default order очищається перед custom sorting;
  5. для рівних значень є deterministic tie-breaker.

Тоді таблиця не тільки правильно сортується. Межа між UI і SQL стає явною, тестованою та значно безпечнішою.