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.2 —
readonlyDTOs, 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):
| Key | Example | Meaning |
|---|---|---|
search | ?search=ann | Global LIKE across searchable columns |
sort | ?sort=name_desc | A key from sortingMapping() |
page | ?page=2 | 1-based page number |
per_page | ?per_page=50 | Page size (max 500) |
filter[<key>] | ?filter[role]=admin&filter[status][]=active | Per-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
FilterType | Request value | SQL |
|---|---|---|
TEXT / TEXT_LIKE | filter[name]=smith | name LIKE %smith% |
TEXT_STRICT | filter[serial]=SN-001 | serial = 'SN-001' |
TEXT_STARTS_WITH | filter[doc]=FV/2026/ | doc LIKE 'FV/2026/%' |
TEXT_ENDS_WITH | filter[doc]=/2026 | doc 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
FilterType | Request value | SQL |
|---|---|---|
NUMBER_EQUALS | filter[year]=2026 | year = 2026 |
NUMBER_RANGE | filter[price]={"from":100,"to":500} | price >= 100 AND price <= 500 |
NUMBER_COMPARISON | filter[stock]={"operator":"lt","value":10} | stock < 10 |
Operators: eq, neq, gt, gte, lt, lte (and their symbolic aliases).
Date & time
FilterType | Request value | SQL |
|---|---|---|
DATE_EQUAL | filter[day]=2026-06-21 | DATE(day) = '2026-06-21' |
DATE_RANGE | filter[created]={"from":"2026-06-01","to":"2026-06-21"} | created >= … AND created <= … |
DATETIME_RANGE | filter[login]={"from":"2026-06-21 08:00:00","to":"…16:00:00"} | login >= … AND login <= … |
DATE_RELATIVE | filter[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
FilterType | Request value | SQL |
|---|---|---|
SELECT | filter[role]=admin | role IN ('admin') |
MULTISELECT | filter[status][]=active&filter[status][]=new | status IN (...) |
BOOLEAN | filter[active]=true | is_active = 1 |
BOOLEAN is tri-state: true/false constrain the column, anything else (the
"all" state) leaves it untouched.
Special / expert mode
FilterType | Request value | SQL |
|---|---|---|
IS_NULL | filter[deleted]=true | deleted_at IS NULL |
IS_NOT_NULL | filter[verified]=true | verified_at IS NOT NULL |
JSON_CONTAINS | filter[tags]=php | JSON_CONTAINS(...) / json_each(...) |
CUSTOM_QUERY | filter[vip]=true | whatever 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
PdoAdapteradditionally validates them against^[A-Za-z_][A-Za-z0-9_]*(\.…)?$. - Sorting is whitelisted through
sortingMapping(), and the direction is forced toASC/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
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-21