定制 sebastian.szyja/php-datatables 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

sebastian.szyja/php-datatables

Composer 安装命令:

composer require sebastian.szyja/php-datatables

包简介

Framework-agnostic PHP engine that automates filtering, searching, sorting and pagination for DataTable-style endpoints.

README 文档

README

A modern, reusable PHP 8.2+ package that automates filtering, global search, sorting and pagination for DataTable-style endpoints.

It separates the definition of a table from the database and from the HTTP layer, so the very same definition works in plain PHP, Laravel, Symfony, Slim — anywhere.

[ HTTP Request ] ──▶ DataTableRequest (DTO)
                              │
        Table definition ──▶ DataTableEngine ──▶ DbAdapterInterface ──▶ DataTableResponse ──▶ JSON
        (your class)              │              (PDO / Doctrine / …)
                       FilterProcessor strategies

The problem

In every controller I kept rewriting the same code: read the search term, bind the filter parameters into SQL, whitelist the sort column so users can't inject one, clamp the page size, build a COUNT(*) for the pager… copied, pasted and subtly different each time. It was repetitive, easy to get wrong, and impossible to test in isolation.

The solution

A metadata-driven engine. You declare a table's structure once — its columns, its allowed filters, its sort whitelist — as typed objects. The engine reads that metadata and automatically builds the query through an Adapter, so it works against any database without ever touching SQL itself.

Each filter type is handled by its own small strategy (a FilterProcessor), so adding a new kind of filter means adding a class — never editing the engine.

Why it stands out

  • Fully typed PHP 8.2readonly DTOs, constructor property promotion, enums.
  • Adapter pattern — the engine speaks to an interface, never to a driver. Ships with a PdoAdapter; swap in Doctrine/Eloquent by writing one class.
  • Strategy pattern — one focused processor per filter family, resolved by a factory. New filters slot in without touching existing code.
  • SOLID by design — a single responsibility per layer (SRP), open for new adapters, filters and processors yet closed for modification (OCP), depending on abstractions rather than a concrete DB (DIP).
  • Safe by default — every value is bound as a parameter; identifiers are validated against a strict allowlist; sort keys and directions are whitelisted.
  • Tested & analysed — a PHPUnit suite (engine orchestration, every processor, and real SQLite end-to-end), PHPStan level 8 and PHP-CS-Fixer, all green.

Installation

composer require sebastian.szyja/php-datatables

Requires PHP ≥ 8.2 and ext-pdo (only if you use the bundled PdoAdapter).

Quick start

1. Describe your table once

use DataTables\Config\AbstractDataTableDefinition;
use DataTables\Config\Column;
use DataTables\Config\Filter;
use DataTables\Config\FilterType;

final class UsersTable extends AbstractDataTableDefinition
{
    public function columns(): array
    {
        return [
            new Column(key: 'id',    dbColumn: 'id',    label: 'ID',    sortable: true),
            new Column(key: 'name',  dbColumn: 'name',  label: 'Name',  sortable: true, searchable: true),
            new Column(key: 'email', dbColumn: 'email', label: 'Email', searchable: true),
        ];
    }

    public function filters(): array
    {
        return [
            new Filter(key: 'role',    dbColumn: 'role',       type: FilterType::SELECT),
            new Filter(key: 'status',  dbColumn: 'status',     type: FilterType::MULTISELECT),
            new Filter(key: 'active',  dbColumn: 'is_active',  type: FilterType::BOOLEAN),
            new Filter(key: 'price',   dbColumn: 'price',      type: FilterType::NUMBER_RANGE),
            new Filter(key: 'created', dbColumn: 'created_at', type: FilterType::DATE_RANGE),
        ];
    }

    /** Whitelist of public sort keys → real column + direction. First entry is the default. */
    public function sortingMapping(): array
    {
        return [
            'default'   => ['column' => 'id',   'direction' => 'ASC'],
            'name_asc'  => ['column' => 'name', 'direction' => 'ASC'],
            'name_desc' => ['column' => 'name', 'direction' => 'DESC'],
        ];
    }

    /** Columns scanned by the global search box. */
    public function searchableColumns(): array
    {
        return ['name', 'email'];
    }
}

2. Run the engine in your controller

use DataTables\DataTableEngine;
use DataTables\Adapters\PdoAdapter;
use DataTables\Dto\DataTableRequest;

$request  = DataTableRequest::fromArray($_GET);     // or a PSR-7 / framework request bag
$adapter  = new PdoAdapter($pdo, table: 'users');
$response = (new DataTableEngine())->process(new UsersTable(), $adapter, $request);

header('Content-Type: application/json');
echo json_encode($response);                         // DataTableResponse is JsonSerializable

3. The JSON it returns

{
  "data": [
    { "id": 1, "name": "Alice", "email": "alice@example.com" }
  ],
  "meta": { "total": 42, "page": 1, "per_page": 25, "last_page": 2 }
}

Request shape

DataTableRequest::fromArray() reads these keys and sanitises them (trims strings, clamps page ≥ 1 and per_page to 1–500):

KeyExampleMeaning
search?search=annGlobal LIKE across searchable columns
sort?sort=name_descA key from sortingMapping()
page?page=21-based page number
per_page?per_page=50Page size (max 500)
filter[<key>]?filter[role]=admin&filter[status][]=activePer-filter values

Empty values, missing keys, and the __no_filter__ sentinel (DataTableEngine::NO_FILTER) are skipped, so an untouched filter never narrows the result set. (false, 0 and '0' are kept — a toggle switched off is a real constraint.)

Filter catalogue

The engine ships with processors for the full range of filters a modern ERP/CRM grid needs. Each row shows the FilterType, the JSON the front-end sends and the resulting SQL.

Text & string

FilterTypeRequest valueSQL
TEXT / TEXT_LIKEfilter[name]=smithname LIKE %smith%
TEXT_STRICTfilter[serial]=SN-001serial = 'SN-001'
TEXT_STARTS_WITHfilter[doc]=FV/2026/doc LIKE 'FV/2026/%'
TEXT_ENDS_WITHfilter[doc]=/2026doc LIKE '%/2026'

Text filters also accept a runtime operator so the user can pick the mode in the UI:

"filter": { "document_number": { "operator": "starts_with", "value": "FV/2026/" } }

Numeric & financial

FilterTypeRequest valueSQL
NUMBER_EQUALSfilter[year]=2026year = 2026
NUMBER_RANGEfilter[price]={"from":100,"to":500}price >= 100 AND price <= 500
NUMBER_COMPARISONfilter[stock]={"operator":"lt","value":10}stock < 10

Operators: eq, neq, gt, gte, lt, lte (and their symbolic aliases).

Date & time

FilterTypeRequest valueSQL
DATE_EQUALfilter[day]=2026-06-21DATE(day) = '2026-06-21'
DATE_RANGEfilter[created]={"from":"2026-06-01","to":"2026-06-21"}created >= … AND created <= …
DATETIME_RANGEfilter[login]={"from":"2026-06-21 08:00:00","to":"…16:00:00"}login >= … AND login <= …
DATE_RELATIVEfilter[login]={"relative":"last_7_days"}resolved server-side to a range

Relative tokens: today, yesterday, tomorrow, last_7_days, last_30_days, this_week, last_week, this_month, last_month, this_year. They are evaluated against an injectable ClockInterface, which makes them fully testable.

Selection & relational

FilterTypeRequest valueSQL
SELECTfilter[role]=adminrole IN ('admin')
MULTISELECTfilter[status][]=active&filter[status][]=newstatus IN (...)
BOOLEANfilter[active]=trueis_active = 1

BOOLEAN is tri-state: true/false constrain the column, anything else (the "all" state) leaves it untouched.

Special / expert mode

FilterTypeRequest valueSQL
IS_NULLfilter[deleted]=truedeleted_at IS NULL
IS_NOT_NULLfilter[verified]=trueverified_at IS NOT NULL
JSON_CONTAINSfilter[tags]=phpJSON_CONTAINS(...) / json_each(...)
CUSTOM_QUERYfilter[vip]=truewhatever your closure builds

JSON_CONTAINS adapts to the active PDO driver (MySQL / SQLite). CUSTOM_QUERY hands the adapter and value to a closure on the filter — your escape hatch for sub-queries the semantic methods can't express:

new Filter(
    key: 'vip',
    dbColumn: 'role',
    type: FilterType::CUSTOM_QUERY,
    callback: function (DbAdapterInterface $adapter, mixed $value): void {
        if (filter_var($value, FILTER_VALIDATE_BOOLEAN)) {
            $adapter->addRawWhere('role = :role AND is_active = 1', ['role' => 'admin']);
        }
    },
);

Extending the engine (Strategy pattern)

Internally the engine never branches on the filter type. It asks a FilterProcessorFactory for the processor registered for that type and delegates:

// inside DataTableEngine::applyFilters()
$this->processors->get($filter->type)->apply($adapter, $filter, $value);

So adding a behaviour is purely additive — write a processor and register it:

use DataTables\Filter\FilterProcessorFactory;

$factory = new FilterProcessorFactory();      // all built-ins…
$factory->register(new FullTextSearchProcessor()); // …plus (or overriding) your own

$engine = new DataTableEngine($factory);

A processor declares which types it handles() and how to apply() them through the adapter's database-agnostic methods.

Bring your own database (the Adapter pattern)

The engine and the processors only know this contract:

interface DbAdapterInterface
{
    public function addWhereLike(array $columns, string $searchTerm, LikeMode $mode = LikeMode::CONTAINS): void;
    public function addWhereComparison(string $column, Comparison $operator, mixed $value): void;
    public function addWhereIn(string $column, array $values): void;
    public function addWhereRange(string $column, mixed $from, mixed $to): void;
    public function addWhereDate(string $column, string $date): void;
    public function addWhereNull(string $column): void;
    public function addWhereNotNull(string $column): void;
    public function addWhereJsonContains(string $column, mixed $value): void;
    public function addRawWhere(string $expression, array $bindings = []): void;
    public function applySorting(string $column, string $direction): void;
    public function applyPagination(int $page, int $perPage): void;
    public function execute(): array;
    public function getTotalCount(): int;
}

Implement it for Doctrine DBAL, Eloquent, MongoDB or an HTTP API and the engine works unchanged. The bundled PdoAdapter is a complete, parameter-binding implementation you can use today.

Security notes

  • Values are always sent as bound parameters — user input never reaches the SQL string.
  • Identifiers (table/column names) come from your definition, not from the request, and PdoAdapter additionally validates them against ^[A-Za-z_][A-Za-z0-9_]*(\.…)?$.
  • Sorting is whitelisted through sortingMapping(), and the direction is forced to ASC/DESC.

Project structure

src/
├── Adapters/
│   ├── DbAdapterInterface.php
│   └── PdoAdapter.php
├── Clock/                       # injectable "now" for relative dates
│   ├── ClockInterface.php
│   └── SystemClock.php
├── Config/
│   ├── AbstractDataTableDefinition.php
│   ├── Column.php
│   ├── Filter.php
│   └── FilterType.php           (enum)
├── Dto/
│   ├── DataTableRequest.php
│   └── DataTableResponse.php
├── Exception/
│   ├── DataTableException.php
│   ├── InvalidFilterConfigurationException.php
│   └── UnsupportedFilterTypeException.php
├── Filter/
│   ├── FilterProcessorInterface.php
│   ├── FilterProcessorFactory.php
│   ├── NumberNormalizer.php
│   ├── RelativeRangeResolver.php
│   └── Processor/               # one strategy per filter family
│       ├── TextLikeProcessor.php
│       ├── ExactMatchProcessor.php
│       ├── ComparisonProcessor.php
│       ├── RangeProcessor.php
│       ├── DateEqualProcessor.php
│       ├── RelativeDateProcessor.php
│       ├── SelectProcessor.php
│       ├── BooleanProcessor.php
│       ├── NullProcessor.php
│       ├── JsonContainsProcessor.php
│       └── CustomQueryProcessor.php
├── Query/
│   ├── Comparison.php           (enum)
│   └── LikeMode.php             (enum)
└── DataTableEngine.php

Development

composer install
composer test      # PHPUnit
composer stan      # PHPStan, level 8
composer cs        # PHP-CS-Fixer (dry run); use cs-fix to apply
composer qa        # cs + stan + test

CI runs the same composer qa on every push, merge request and tag (see .gitlab-ci.yml).

License

Released under the MIT License.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 1
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-21

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固