flo-labs / dms
PHP parser for DMS — a data syntax with strong typing, ordered maps, multi-line heredocs, and front-matter metadata.
Requires
- php: >=8.1
- ext-intl: *
Requires (Dev)
- phpbench/phpbench: ^1.6
- phpunit/phpunit: ^12.5
- symfony/yaml: ^7.4
- yosymfony/toml: ^1.0
README
dms-php
PHP parser for DMS, a data syntax with strong typing, ordered maps, multi-line heredocs, and front-matter metadata.
Two packages live in this repo, both with the same API and value shape:
| package | implementation | when to use |
|---|---|---|
flo-labs/dms | pure PHP | shared hosting, no FFI required |
flo-labs/dms-c | php_ffi binding to the dms-c C parser | hot paths; ~7× faster than pure PHP |
What DMS looks like
A medium-size tier-0 document, exercising every feature you'd touch in a
real config — front matter, comments (line + trailing), nested tables,
list-of-tables with the + marker, flow forms, distinct types, and a
heredoc with a trim modifier:
+++
title: "DMS feature tour"
version: "1.0.0"
updated: 2026-04-24T09:30:00-04:00
+++
# Hash and // line comments both work.
// Bare keys allow full Unicode; quoted keys take any string.
database:
host: "db.internal"
port: 5432 # bumped after the LB change
pool: { size: 10, idle_timeout_s: 30 } # flow table
servers:
+ name: "web1"
disks:
+ mount: "/"
size_gb: 100
+ mount: "/var"
size_gb: 500
+ name: "web2"
regions: ["us-east-1", "eu-west-1", "ap-south-1"]
sql: """SQL _trim("\n", ">")
SELECT id, email
FROM users
WHERE active = true
SQL
Tier 1 layers structured decorators on top of the value tree. Sigils bind
to families published by a dialect; here is dms+html carrying an HTML
fragment as a DMS document:
+++
_dms_tier: 1
_dms_imports:
+ dialect: "html"
version: "1.0.0"
+++
+ |html(lang: "en")
+ |head
+ |title "DMS feature tour"
+ |meta(charset: "UTF-8")
+ |body(class: "main")
+ |h1 "Welcome to DMS"
+ |p(class: "lede")
+ "Click "
+ |a(href: "/spec.html") "here"
+ " to read the spec."
Full feature tour, format comparison, and dialect index on the DMS website.
Install
Status: not yet on Packagist. Until the first release lands there, consume this port by adding the GitLab repository to your
composer.jsonand requiringflo-labs/dmsfrom it:{ "repositories": [ { "type": "vcs", "url": "https://gitlab.com/flo-labs/pub/dms-php" } ], "require": { "flo-labs/dms": "^0.5" }, "minimum-stability": "dev" }Once the package is published, the install command will simply be:
# pure PHP: composer require flo-labs/dms # native (C) parser via FFI: composer require flo-labs/dms-c
The dms-c package's composer install runs sh build.sh to compile
dms.dll / libdms.so from the vendored sources. You need a C
compiler (gcc or clang) and the php_ffi extension enabled.
Usage
use Dms\Parser;
use Dms\Emitter;
use DmsC\DmsC;
$src = file_get_contents("config.dms");
// Full document (preserves comments + literal forms for encode round-trip).
$doc = Parser::decode($src); // pure PHP — returns Dms\Document
// $doc = DmsC::decode($src); // native (C), same API
$meta = $doc->meta; // Dms\Table | null
$body = $doc->body;
$comments = $doc->comments; // list of Dms\AttachedComment
$originalForms = $doc->originalForms; // list of [path, OriginalLiteral]
// Re-emit DMS source.
$output = Emitter::encode($doc);
0.3.0 rename: the old names
Parser::parse()/Parser::parseLite()andEmitter::toDms()/Emitter::toDmsLite()still work as wrappers that emitE_USER_DEPRECATEDand forward to the canonicaldecode/decodeLite/encode/encodeLite. They will be removed in a future release. The same rename applies to the FFI tier (DmsC::parse→DmsC::decode, etc.).The conformance JSON encoder (formerly
Dms\Encoder) is nowDms\TaggedJsonEncoder, so itsencode()no longer collides withDms\Emitter::encode()(DMS source round-trip). And the parser's exception type was renamedDms\ParseError→Dms\DecodeException, withDms\EncodeExceptionadded as the matching emitter-error class —ParseErrorstays as a deprecated subclass alias.
Tables are insertion-ordered associative arrays. Lists are arrays.
Datetimes are wrapped types: the pure module returns Dms\LocalDate /
Dms\LocalTime / Dms\LocalDateTime / Dms\OffsetDateTime class
instances; the C package returns plain ['__dms_type' => '...', 'value'
=> '...'] arrays with the same data. Encoders that detect via
__dms_type + value work unchanged across both packages.
Working with comments and heredocs
DMS preserves comments through parse → mutate → re-emit (SPEC
§Comments). Dms\Document is immutable — its meta, body,
comments, and originalForms fields are readonly. To attach a
comment programmatically, build the new comments array and use the
withComments clone helper (PSR-7 style):
use Dms\Parser;
use Dms\Emitter;
use Dms\AttachedComment;
use Dms\Comment;
$doc = Parser::decode("db:\n port: 8080\n");
// Build the new comments list with one extra leading line comment.
$comments = [...$doc->comments,
new AttachedComment(
comment: new Comment(content: '# bumped after LB change', kind: 'line'),
position: 'leading',
path: ['db', 'port'],
),
];
// Body is a Dms\Table; mutating the underlying value follows the same
// "build-then-replace" pattern via withBody if you need to substitute it.
echo Emitter::encode($doc->withComments($comments));
Forcing a heredoc on emit
Strings parse and re-emit in their source form. To switch a basic-quoted
string to a heredoc (or to construct one from scratch), append an
OriginalLiteral::string(...) record keyed by the value's path and
swap it in via withOriginalForms:
use Dms\OriginalLiteral;
use Dms\StringForm;
use Dms\HeredocFlavor;
$forms = [...$doc->originalForms,
[
['db', 'greeting'],
OriginalLiteral::string(StringForm::heredoc(
HeredocFlavor::basicTriple(), // or ::literalTriple() for '''
null, // null = unlabeled
[], // _trim(...), _fold_paragraphs(), …
)),
],
];
$doc = $doc->withOriginalForms($forms);
Round-trip rules (SPEC §Round-trip semantics): comments stick to
still-present nodes; deleting a node drops its comments; newly
inserted nodes start with no comments. The first originalForms
entry per path wins, so override a parser-recorded form by replacing
rather than appending if the key is already present.
Performance
50,000-key flat document (~700 KB), best-of-5, startup-subtracted, PHP 8.3 on Windows 11:
| tier | DMS port | time | JSON peer | time | YAML peer | time | DMS / JSON | DMS / YAML |
|---|---|---|---|---|---|---|---|---|
| pure PHP | dms | 328 ms | n/a | — | symfony/yaml | 290 ms | n/a | 1.13× |
| native (C) | dms-c | 45 ms | json_decode | 16 ms | n/a (no FFI YAML) | — | 2.81× | n/a |
The FFI binding cuts parse time 7.3× vs pure PHP. Against C-backed peers DMS sits at ~2.8× JSON cost — the standard cost of carrying comments, ordered keys, and source-form metadata.
PHP's stdlib json_decode is C-backed, so JSON only appears in the
FFI tier (no widely-used pure-PHP JSON parser to compare against).
symfony/yaml is pure PHP — the de-facto YAML library — so it sits
in the pure tier; there's no widely-used libyaml binding for PHP that
ships across distros, so YAML doesn't appear in the FFI row.
Reproduce with:
composer install # pulls symfony/yaml + phpbench
sh dms-c/build.sh # builds dms.dll / libdms.so
php -d extension=ffi -d ffi.enable=true \
bench/run_bench_decoders.php
Build & test
# pure package:
composer install
vendor/bin/phpunit
# native (FFI) package:
sh dms-c/build.sh
Conformance
The fixture corpus lives in dms-tests (4500+ pairs). Clone it once as a sibling:
cd ..
git clone https://gitlab.com/flo-labs/pub/dms-tests.git
The bin/dms-encoder binary reads DMS from stdin and writes tagged JSON
to stdout, matching the format the conformance runner consumes. The
dms-c package passes 3931/3931 valid fixtures.
License
Dual-licensed: MIT or Apache-2.0, your choice.
