Unofficial. This is an independent, community-maintained port. It is not affiliated with, endorsed by or supported by Developer Express Inc. "DevExtreme" and "DevExpress" are trademarks of Developer Express Inc.
Server-side data processing for DevExtreme widgets in PHP — a port of
DevExtreme.AspNet.Data.
It understands the request the DevExtreme client sends (filter, sort, group, skip, take,
totalSummary, groupSummary, select, ...) and answers in the exact shape the client expects.
- Arrays / iterables / objects — every feature, in memory (
ArraySource). - SQL through PDO — SQLite, MySQL/MariaDB, PostgreSQL (
PdoSource). Filtering, sorting, paging, select, counts and summaries run in the database; collapsed groups use a singleGROUP BY. - Framework agnostic. Requires PHP 8.1+,
ext-json,ext-mbstring(+ext-pdofor SQL).
composer require omerkoseoglu/devextreme-datause DevExtreme\Data\DataSourceLoader;
use DevExtreme\Data\PdoSource;
$source = new PdoSource($pdo, 'orders', primaryKey: ['id']);
header('Content-Type: application/json');
echo json_encode(DataSourceLoader::loadFromRequest($source)); // reads $_GET + $_POST// client
const store = DevExpress.data.AspNet.createStore({ key: 'id', loadUrl: '/api/orders' });
$('#grid').dxDataGrid({ dataSource: store, remoteOperations: true /* ... */ });Arrays work the same way:
echo json_encode(DataSourceLoader::load($arrayOfRows, $_GET));load() accepts raw request parameters (JSON strings or decoded arrays) or a LoadOptions instance,
and returns a LoadResult (data, totalCount, groupCount, summary) that is JsonSerializable.
Malformed input throws InvalidArgumentException — answer with HTTP 400.
| Feature | Array | PDO |
|---|---|---|
Filter: = <> > >= < <=, contains, notcontains, startswith, endswith, nested and/or, ["!", ...] |
✔ | ✔ |
Sort (multi-key, stable), defaultSort, primaryKey tie-break |
✔ | ✔ |
Paging, requireTotalCount, isCountQuery |
✔ | ✔ |
Grouping (multi-level, isExpanded: false → counts only), requireGroupCount |
✔ | ✔ |
Group intervals: numeric ranges, year quarter month day dayOfWeek hour minute second |
✔ | ✔ |
Total & group summaries: sum min max avg count |
✔ | ✔ |
select, preSelect, dotted paths (customer.name) |
✔ | ✔ |
Custom aggregators (CustomAggregators::register) |
✔ | ✔ (computed in PHP) |
Custom filter operations (CustomFilterCompilers::registerBinary) |
✔ | ✔ |
paginateViaPrimaryKey, remoteSelect, remoteGrouping |
– | ✔ |
Objects, getters (getX()/isX()), ArrayAccess, DateTimeInterface, generators |
✔ | – |
Properties mirror the client option names: requireTotalCount, requireGroupCount, isCountQuery,
isSummaryQuery, skip, take, sort, group, filter, totalSummary, groupSummary, select,
preSelect, primaryKey, defaultSort, stringToLower, sortByPrimaryKey, paginateViaPrimaryKey,
remoteSelect, remoteGrouping. Build it with LoadOptions::fromArray($_GET) or set properties directly.
new PdoSource(
$pdo, // must use PDO::ERRMODE_EXCEPTION
'orders', // table/view, or trusted raw FROM with rawFrom: true
columns: ['id' => 'o.id', 'customer.name' => 'c.name'], // optional whitelist: field => SQL expression
primaryKey: ['id'],
where: 'tenant_id = ?', whereParams: [7], // always applied
// rawFrom: true + fromParams: [...] lets $from be a sub-select with bound parameters
);Security. Filter values are always bound parameters. Field names from the client are never
interpolated unless they are a key of the columns whitelist or a plain identifier
(/^[A-Za-z_][A-Za-z0-9_]*$/). Pass columns in production to expose only the fields you intend.
from, where and the columns expressions are your trusted SQL.
Semantics worth knowing
NULLnever satisfies a comparison and is matched by<>— identical in both sources, so["!", ...]agrees too.- String case:
ArraySourcecompares case-insensitively by default (stringToLower: true);PdoSourceleaves it to the database collation (false).contains/startswith/endswithare case-insensitive:LIKEon SQLite/MySQL (MySQL follows the column collation),ILIKEon PostgreSQL. - Sorting strings: case-insensitive in memory, collation-defined in SQL.
NULLsorts first ascending in both. - Groups with a
groupIntervalare ordered by the interval key (Jan..Dec), not by the raw value.
use DevExtreme\Data\Aggregation\{Aggregator, CustomAggregators};
use DevExtreme\Data\Filter\{BinaryExpressionInfo, CustomFilterCompilers};
use DevExtreme\Data\Sql\SqlFragment;
CustomAggregators::register('median', fn () => new MedianAggregator()); // extends Aggregator
CustomFilterCompilers::registerBinary(function (BinaryExpressionInfo $i) {
if ($i->operation !== 'anyof') return null; // ["category", "anyof", ["a", "b"]]
return $i->target === 'sql'
? new SqlFragment($i->columns->resolve($i->field) . ' IN (?, ?)', $i->value)
: fn ($item) => in_array(Accessor::read($item, $i->field), $i->value, true);
});- Dates and timezones. ISO-8601 filter values (
2024-05-01T10:00:00.000Z) are compared as wall-clock time; the timezone designator is ignored, nothing is converted. A browser serialises a localDateas UTC, so a record saved at 10:00 in UTC+3 is stored as 07:00 unless your app converts. Store and compare in one timezone. - Date-only filters in SQL (
["at", ">=", "2024-05-01"]) are bound as the plain string and compared by the database with the stored value;ArraySourcetreats it as midnight. Equality on a datetime column therefore differs between the two. - PostgreSQL date intervals (
year,month, ... grouping) need realdate/timestampcolumns;EXTRACTdoes not accept text columns. - Paging order. Without a primary key (
primaryKeyoption) ordefaultSort, SQL does not guarantee a stable order between pages. Set one. - Expanded groups load every matching row. Like the original,
PdoSourcebuilds expanded groups in PHP from the filtered, sorted rows (paging applies to top-level groups afterwards). Collapsed groups (isExpanded: false, which is what DataGrid and PivotGrid request) useGROUP BYand are cheap. Custom aggregators also force this in-PHP path. ArraySourceholds everything in memory. Use it for small or already-loaded data, not for large tables.- String ordering and equality follow the database collation in SQL, case-insensitive comparison in memory (see above).
LIKEcase behaviour on MySQL follows the column collation. - Numbers. SQL
SUM/AVGresults are normalised toint/float; very large or high-precisionDECIMALsums can lose precision. Booleans come back as0/1on most drivers. - Rows with
DateTimeInterfacevalues (inArraySource) are serialised byjson_encodeas objects; convert them to strings first. Group keys that are dates are emitted as ISO-8601. - Databases. SQLite, MySQL/MariaDB and PostgreSQL only. For others extend
Sql\Dialectand pass it toPdoSource.
- Laravel / Eloquent:
omerkoseoglu/devextreme-data-laravel - Symfony / Doctrine:
omerkoseoglu/devextreme-data-symfony
composer install
composer demo # http://localhost:8000A SQLite database (3000 orders) is created on first request. Pages: DataGrid with full CRUD and server-side everything, PivotGrid with date intervals, and an in-memory array example. Client scripts load from the DevExpress and jsDelivr CDNs.
composer test # PHPUnit (unit + integration + SQL-vs-memory parity)
composer analyse # PHPStan level 6
composer cs / cs:fix # PHP-CS-FixerThe same contract test-suite runs against SQLite by default and against MySQL/PostgreSQL when
DEVEXTREME_TEST_MYSQL_DSN / DEVEXTREME_TEST_PGSQL_DSN are set (see phpunit.xml.dist; CI provides both).
These tests drop and recreate a table named orders — point them at a throwaway database.
MIT. Original work © Developer Express Inc.; see LICENSE.