Parser for eXeLearning project files with support for legacy contentv3.xml projects and modern ODE content.xml packages.
Features | Installation | Usage
ELPParser supports:
- Legacy
.elpprojects from eXeLearning 2.x based oncontentv3.xml - Modern
.elp/.elpxprojects based oncontent.xmland ODE 2.0 - eXeLearning 3 and 4 ELPX package conventions without treating them as different XML format versions
- Explicit and heuristic eXeLearning major-version detection with detection details
- Normalized metadata, strings, pages, blocks, iDevices and asset references
- Normalized iDevice state across standard JSON, DataGame, embedded JSON and HTML-only storage patterns
- Asset discovery in HTML, CSS-like values,
srcsetand structuredjsonProperties - Archive-backed asset normalization and orphan-asset detection
- Safe ZIP extraction with path traversal and symlink checks
- Configurable limits for entry count, decompressed sizes, XML size and compression ratio
- Streaming extraction to avoid loading large assets into memory
- JSON serialization
For more information, visit the documentation. The published site includes both the curated API guide and a PHPDoc-generated class/reference index. The repository also includes an examples/ directory with executable recipes for inspection, validation, assets, diffs, fingerprints and extraction.
- PHP 8.0+
- Composer
ext-zipext-simplexml
Composer declares these extensions and will report a missing requirement during installation.
composer require exelearning/elp-parserComposer exposes vendor/bin/elp-parser for common inspection and automation workflows:
vendor/bin/elp-parser inspect course.elpx
vendor/bin/elp-parser validate course.elpx
vendor/bin/elp-parser manifest course.elpx
vendor/bin/elp-parser assets course.elpx
vendor/bin/elp-parser json course.elpx --detailed
vendor/bin/elp-parser diff old.elpx new.elpx
vendor/bin/elp-parser fingerprint course.elpx
vendor/bin/elp-parser extract course.elpx output/Use --json with inspect, validate and fingerprint for machine-readable output. See the CLI documentation for exit-code semantics.
Projects can be parsed from PHP streams or in-memory bytes. Stream input is copied in chunks to a bounded temporary file because ZipArchive requires a filesystem path:
$stream = fopen($_FILES['project']['tmp_name'], 'rb');
$parser = ELPParser::fromStream($stream, 'elpx');
$parserFromBytes = ELPParser::fromContents($bytes, 'elpx');Temporary files owned by parser instances are removed automatically. inspectStream() and inspectContents() provide the corresponding lightweight inspection APIs.
Use the lightweight detection helpers before full parsing when the input type is unknown:
if (ELPParser::supports($path)) {
echo ELPParser::identify($path); // legacy-v2, elpx-v4, ...
$info = ELPParser::probe($path); // alias of lightweight inspect()
}ArchiveLimits remains accepted as the second argument for backward compatibility. New feature switches use ParserOptions:
use Exelearning\Archive\ArchiveLimits;
use Exelearning\ParserOptions;
$options = new ParserOptions(
archiveLimits: new ArchiveLimits(maxXmlBytes: 32 * 1024 * 1024),
parseAssets: false,
collectStrings: false,
normalizeIdeviceState: false
);
$parser = ELPParser::fromFile($path, $options);Disable work only when the corresponding derived data is not needed.
For cataloging or indexing, inspect() reads archive metadata and the project XML without normalizing pages, iDevices or assets:
$info = ELPParser::inspect('/path/to/project.elpx');
echo $info['title'];
echo $info['formatVersion'];
echo $info['packageProfile'];use Exelearning\ELPParser;
$parser = ELPParser::fromFile('/path/to/project.elpx');
echo $parser->getTitle();
echo $parser->getVersion();
foreach ($parser->getStrings() as $string) {
echo $string . "\n";
}echo $parser->getSourceExtension(); // elp | elpx
echo $parser->getContentFormat(); // legacy-contentv3 | ode-content
echo $parser->getContentFile(); // contentv3.xml | content.xml
echo $parser->getContentSchemaVersion(); // 2.0 for modern ODE packages
echo $parser->getFormatFamily(); // legacy | ode
echo $parser->getFormatVersion(); // null | 2.0
echo $parser->getExeVersion(); // raw upstream version string when present
echo $parser->getApplicationVersion(); // alias with explicit application semantics
echo $parser->getPackageProfile(); // legacy-v2 | elpx-v3 | elpx-v4 | ode-v3 ...
echo $parser->getResourceLayout(); // none | content-resources | legacy-temp-paths | mixed
echo $parser->getResourceProfile(); // v3-uuid-resources | v4-resource-tree | mixed-modern-resources | ...
$versionInfo = $parser->getVersionInfo();
// declared, declaredMajor, detectedMajor, source, signalsgetVersion() remains the compatibility API for the detected major version. getVersionInfo() makes it explicit whether that result came from package metadata, the package format, a heuristic, or a default.
$pages = $parser->getPages();
$visiblePages = $parser->getVisiblePages();
$blocks = $parser->getBlocks();
$idevices = $parser->getIdevices();
// Modern iDevices expose storagePattern, data and stateDecodeError.
$pageTexts = $parser->getPageTexts();
$assets = $parser->getAssets();
$assetsDetailed = $parser->getAssetsDetailed();
$orphanAssets = $parser->getOrphanAssets();
$missingAssets = $parser->getMissingAssets();
$brokenReferences = $parser->getBrokenReferences();
$internalLinks = $parser->getInternalLinks();
$brokenInternalLinks = $parser->getBrokenInternalLinks();
$manifest = $parser->getPackageManifest();
$metadata = $parser->getMetadata();
$userPreferences = $parser->getUserPreferences();
$odeResources = $parser->getOdeResources();
$odeProperties = $parser->getOdeProperties();
$pageTree = $parser->getPageTree();Direct lookup helpers are also available: getPageById(), getBlockById(), getIdeviceById(), getProjectId() and getProjectVersionId().
A parallel typed API is available through $parser->getProject(). It returns Project, Page, Block, Idevice, Asset and VersionInfo model objects while the existing array APIs remain unchanged.
Asset references are normalized against the actual ZIP entries. This prevents external URLs and nonexistent paths from being reported as package assets.
The existing validation arrays remain supported. A typed wrapper is available when object APIs are preferable:
$result = $parser->validateResult();
if (!$result->isValid()) {
foreach ($result->errors() as $diagnostic) {
echo $diagnostic->getCode() . ': '
. $diagnostic->getMessage() . PHP_EOL;
}
}Domain-specific iDevice decoding can be plugged in without modifying the parser:
use Exelearning\Parser\IdeviceDecoderInterface;
use Exelearning\Parser\IdeviceDecoderRegistry;
use Exelearning\ParserOptions;
$registry = new IdeviceDecoderRegistry([
new MyIdeviceDecoder(),
]);
$options = new ParserOptions(ideviceDecoders: $registry);
$parser = ELPParser::fromFile($path, $options);Custom decoder output is exposed as customDecoder and customData on matching normalized iDevices.
Normal parsing remains tolerant. Validation can be requested explicitly:
$result = $parser->validate();
if (!$result['valid']) {
print_r($result['errors']);
}
print_r($result['warnings']);The validator reports unresolved assets, broken internal exe-node: links, duplicate identifiers, broken page-parent relationships, hierarchy cycles, relationship/order inconsistencies, missing iDevice runtime directories and missing v4 baseline files/directories.
Schema validation is optional and only uses a caller-supplied trusted local schema:
use Exelearning\Validation\SchemaValidator;
$xsdResult = $parser->validateSchema('/trusted/path/ode-content.xsd');
$dtdResult = $parser->validateSchema(
'/trusted/path/content.dtd',
SchemaValidator::TYPE_DTD
);Package-supplied DTDs are not trusted for this API. Schema loading uses LIBXML_NONET. The optional schema-validation API requires ext-dom.
Default limits are intentionally generous but bounded. They can be overridden for trusted or unusually large packages:
use Exelearning\Archive\ArchiveLimits;
use Exelearning\ELPParser;
$limits = new ArchiveLimits(
maxEntries: 30000,
maxEntryBytes: 1073741824,
maxTotalBytes: 2147483647,
maxXmlBytes: 134217728,
maxCompressionRatio: 1000.0
);
$parser = ELPParser::fromFile('/path/to/project.elpx', $limits);The defaults are 20,000 entries, 1 GiB per entry, approximately 2 GiB total uncompressed data, 64 MiB for the project XML, and a maximum compression ratio of 1000:1.
Exact archive bytes and normalized logical content use separate fingerprints:
$archiveHash = $parser->getArchiveFingerprint();
$contentHash = $parser->getContentFingerprint();
$same = $parser->hasSameContentAs($otherParser);
$diff = $parser->diff($otherParser);The normalized content fingerprint ignores volatile package identity/version fields such as odeId, odeVersionId and the eXeLearning application version. It keeps parsed project structure and includes hashes of project resource bytes. This makes it suitable for change detection without treating ZIP packaging differences as content changes.
The semantic diff reports metadata, page, block, iDevice and resource additions/removals/changes.
Detailed exports carry an explicit schema version and ship with a bundled JSON Schema:
echo ELPParser::getDetailedSchemaVersion(); // 1.0
$schema = ELPParser::getDetailedJsonSchema();
$schemaPath = ELPParser::getDetailedJsonSchemaPath();The detailed schema version is independent from the eXeLearning application version, ODE format version and Composer package version.
$json = $parser->exportJson();
$parser->exportJson('/path/to/output.json');
$detailedJson = $parser->exportDetailedJson();
$detailed = $parser->toDetailedArray();The existing JSON export remains compact. The detailed representation adds format/version information, metadata, ODE preferences/resources/properties, pages and page tree, blocks, iDevices, assets and archive entries.
For large assets, use stream access instead of loading the complete entry into memory:
if ($parser->hasEntry('content/resources/video.mp4')) {
$output = fopen('/tmp/video.mp4', 'wb');
try {
$parser->copyEntryToStream(
'content/resources/video.mp4',
$output
);
} finally {
fclose($output);
}
}
$smallFile = $parser->getEntryContents(
'content/resources/config.json',
1024 * 1024
);
$parser->extractEntry(
'content/resources/image.png',
'/tmp/image.png'
);Per-entry reads and copies honor configured archive limits and reject unsafe ZIP entry paths/symlinks.
$parser->extract('/path/to/destination');Extraction is streamed entry by entry. Unsafe paths, ZIP symlinks and extraction targets that resolve outside the destination root are rejected.
Parser errors derive from Exelearning\Exception\ElpParserException. More specific exceptions include:
InvalidArchiveExceptionInvalidXmlExceptionUnsupportedFormatExceptionUnsafeArchiveExceptionResourceLimitException
use Exelearning\ELPParser;
use Exelearning\Exception\ElpParserException;
try {
$parser = ELPParser::fromFile('/path/to/project.elpx');
} catch (ElpParserException $exception) {
echo $exception->getMessage();
}The parser distinguishes the internal project format from the detected eXeLearning application/package version:
- legacy
contentv3.xmlprojects report major version2 - eXeLearning 3 and 4 use the same modern ODE
content.xmlformat with rootversion="2.0" - modern packages accept both the historical
eXeVersionresource key and the currentexe_versionkey .elpx+content.xml+ a rootcontent.dtdremains the current signal for likely v4-style packages when embedded metadata still reports3.0- v3 UUID asset directories and the v4 resource-tree layout are reported separately through
getResourceProfile() - multi-digit future major versions such as
10.xcan be parsed from version metadata
Use getFormatVersion() for the ODE format version, getApplicationVersion() for the declared eXeLearning version, and getVersionInfo() when the distinction between declared and inferred versions matters.
Parsed projects are indexed by page, block and iDevice ID for constant-time lookup. Aggregate collections and diagnostics are cached because parser instances are immutable after construction. Asset-reference resolution also caches normalized archive lookups.
The upstream compatibility corpus records per-project timings, total elapsed time, peak memory and the five slowest projects. These measurements are informational and do not impose brittle timing thresholds in CI.
The test suite enforces a minimum 90% project coverage locally and in CI. The coverage job produces a Clover report and uploads it to Codecov. Codecov also requires at least 90% project and patch coverage for pull requests.
composer test:coverageIn addition to the 90% line-coverage gate, source changes are mutation-tested with Pest. Pull requests mutate changed covered code, while a weekly/manual workflow runs the full covered source with a 50% minimum mutation score (initial full-run baseline: 53.66%). See Mutation testing.
The regular test suite includes a deterministic corpus for malformed XML, encoded and Unicode asset paths, malformed iDevice state and cyclic page hierarchies.
A separate Upstream Compatibility workflow runs weekly against project fixtures from exelearning/exelearning. It discovers ZIP-backed .elp / .elpx fixtures containing content.xml or contentv3.xml, compares lightweight inspection with full parsing, and fails on compatibility regressions.
The corpus runner can also be used locally:
php tests/upstream-compat.php /path/to/exelearning/test/fixtures- Getting started
- Supported formats
- Validation
- Security
- iDevices
- Assets and package entries
- Performance
- Cookbook
- Contributing
- Upgrading
- Security policy
- Changelog
Tagged releases publish a CycloneDX SBOM, SHA-256 checksums and GitHub artifact attestations for build provenance and the SBOM. See Release supply chain.
The project is distributed under the MIT License. See LICENSE.md.