elasticms/xliff
XLIFF is a PHP library that generates and loads XLIFF files from associative arrays, supporting both regular text and HTML content through segmentation. It simplifies exchanging translatable content with translation tools and localization workflows.
Install
composer require elasticms/xliff
There is no Laravel service provider, config file, or facade — this is a framework-agnostic PHP library. It registers nothing; you use its classes directly. That also means no auto-discovery to wait for: if the class resolves in php artisan tinker, you're done.
Where to look first
https://ems-project.github.io/#/dev/xliff/index.md — this is the only prose documentation, and it is thin.src/ tree of the installed package (or the ems-project/xliff mirror). The README describes what it does (associative array ⇄ XLIFF, text + HTML segmentation) but not the class names. Read src/ for the generator and reader entry points before writing production code — the API has moved between 6.x and 7.x.7.x tracks the elasticMS 7 line and modern PHP; 6.9.x is the maintenance branch. Don't float on dev-main — the GitHub repo is a read-only split of the elasticMS monorepo, so PRs and fixes land in ems-project/elasticms, not here.First use case: round-trip one Eloquent model
The canonical workflow is export → translate → import. Start with a single model and a single target locale:
// app/Services/Xliff/ProductExporter.php
namespace App\Services\Xliff;
use App\Models\Product;
class ProductExporter
{
/**
* @return array<string, string> keys become trans-unit ids
*/
public function segments(Product $product): array
{
return [
"product.{$product->id}.name" => $product->name,
"product.{$product->id}.short_description" => $product->short_description,
"product.{$product->id}.body" => $product->body, // HTML
];
}
}
Feed that array to the generator with a source and target language, write the resulting XML to a disk, and hand the file to a translator. On return, load the file back into an associative array, then match keys back onto the model's translation table. Everything in the package is about those two operations; the Laravel work is the plumbing around them.
A good first milestone: an Artisan command xliff:export {product} {locale} that writes products/{id}/fr.xlf to storage/app/xliff, plus xliff:import {path} that applies <target> values. Ship that before designing a bulk pipeline — it exercises every quirk (keys, HTML segments, encoding) at low volume.
1. Wrap the library in one service, not fifty call sites
Because there's no facade, every use of the package is a hard dependency spread across your codebase. Put it behind a small service so you can swap it (or upgrade blindly) later:
class XliffFile
{
public function export(array $segments, string $source, string $target): string
{
// generator call from the package -> XLIFF XML string
return $xml;
}
public function import(string $xml): array
{
// reader call -> ['product.12.body' => '<p>Bonjour</p>', ...]
return $values;
}
}
Then bind it in a service provider and inject. This is the single highest-value integration step with a 0-star dependency.
2. Stable, namespaced keys — treat them as a contract
The associative array keys become the trans-unit identifiers in the file. Translators and tools (memoQ, Trados, Weblate) will send the file back with those ids untouched, so they are your join key on import. Adopt a convention up front:
{entity}.{primary_key}.{field} product.12.body
{entity}.{primary_key}.{field}.{index} product.12.blocks.3 (repeater/block fields)
Never use array indices alone, and never use the translated text itself as a key. If a key disappears between export and import, that's a deleted field — decide explicitly whether import deletes, ignores, or flags it.
3. Export as a queued job, keyed by a batch record
class ExportProductXliff implements ShouldQueue
{
public function __construct(
public int $productId,
public string $locale,
public int $batchId,
) {}
public function handle(ProductExporter $exporter, XliffFile $xliff): void
{
$product = Product::findOrFail($this->productId);
$xml = $xliff->export($exporter->segments($product), config('app.locale'), $this->locale);
Storage::disk('xliff')->put(
"{$this->batchId}/products/{$product->id}.xlf",
$xml
);
XliffBatchItem::where(...)->update(['status' => 'exported']);
}
}
A batch record gives you a resumable, auditable trail — essential because a dropped file between export and import is the most common failure in translation workflows, and the library can't help you there.
4. Split plain and HTML fields deliberately
The package segments HTML into translatable units. That's the reason to use it, but it means the returned value for an HTML field is not a single string you can strip_tags back. Design your model so HTML fields and plain fields are distinguishable at export time (e.g. a $translatableHtml = ['body', 'description'] property on the model), because import handling differs: plain fields are assigned, HTML fields must be reassembled from segments before saving. Mixing them in one array without that metadata is how people lose <strong> tags in production.
5. Multiple locales = multiple files, not one polyglot file
Generate one file per target locale ({key}.fr.xlf, {key}.de.xlf). It keeps the same segment array, so ProductExporter::segments() is reused unchanged, and it maps cleanly to a product_translations table with a locale column. Trying to pack several targets into one document complicates both the generator call and the translator's tooling.
6. Import as a two-phase operation
Parse first, validate second, write third:
public function handle(ImportXliff $job, XliffFile $xliff): void
{
$incoming = $xliff->import(Storage::disk('xliff')->get($job->path));
$expected = $exporter->segments(Product::findOrFail($job->productId));
$missing = array_diff_key($expected, $incoming);
$unknown = array_diff_key($incoming, $expected);
DB::transaction(function () use ($incoming, $missing, $unknown, $job) {
// apply only keys present in both, log the rest to a review table
});
}
Never blind-write whatever the file contains. A translator's tool will happily return stale ids from a three-week-old export.
7. Round-trip test as your upgrade safety net
Because the package is niche and the API is under-documented, the cheapest insurance is a Pest/PHPUnit test that exports a fixture array containing a plain string, an HTML string with nested inline tags, and a UTF-8 string with accents/emoji, then imports it back and asserts equality. Run it in CI on every composer update. This catches XLIFF-version regressions instantly — the release notes here are link-only ("Full Changelog"), so you will not get a changelog telling you what broke.
The README is not documentation of the API. It states capability, not class names. The linked docs site is the primary reference; after that, src/. Budget an hour for a spike before estimating. Verify whether your installed version targets XLIFF 1.2, 2.x, or both — the two formats differ structurally (trans-unit vs <unit>/<segment>, <target> vs <segment><target>) and choosing the wrong one will get files rejected by your translator's CAT tool.
Keys are the whole ballgame. The array keys are the contract with the translator. Unstable keys (autoincrement ids that change on re-import, hashes of content that shift when a comma moves) produce orphaned translations and silent data loss. Use persisted ids.
HTML segmentation is lossy on the way back if you're careless. Expect the reader to return the HTML field as segmented content, not as your original markup string. You need the reassembly step, and you need to accept that inline-tag order may not be byte-identical to the source — assert semantic equivalence (<p> count, text content, hrefs) in tests, not string equality. This bites hardest with nested tags, self-closing tags (<br/>, <img/>), and attributes that contain translatable text (e.g. alt, title) — decide whether those are in scope or explicitly excluded, because default extractors may not pick them up.
Parse defensively. XLIFF from third parties is untrusted input. Use the library's reader (which should be DOM-based) rather than rolling your own simplexml_load_string on the raw upload, and if you do touch raw XML, wrap with libxml_use_internal_errors(true) and disable network access (LIBXML_NONET) to avoid XXE and to keep malformed files from throwing warnings into your logs. Store the uploaded file exactly as received before parsing — you'll want it for forensics when an import half-fails.
Memory. A DOM-based parser loads the whole document. For a single product's fields that's nothing; for a "export the whole catalogue" batch it is a footgun. Keep files per entity (or per few hundred entities), fan out with queued jobs on Laravel Horizon, and never build a 50k-unit XLIFF in a web request. If you must handle large files, chunk at the entity level — the library gives you arrays, so the chunk boundary is your choice.
No config means no defaults to override. Language codes, file naming, and unit ids are your responsibility. Wrap them in config/xliff.php in your app so source_language, disk, and targets aren't scattered as literals. Also guard against app()->getLocale() returning en_US-style codes when the tool expects en-US — normalize before generating.
Licensing. LGPL-3.0 is fine for a Laravel app that consumes the library as a Composer dependency. The obligation attaches if you modify the library and distribute the result — which is exactly the scenario the read-only mirror pushes you into, since fixes must go upstream to ems-project/elasticms. Fork through the monorepo, and prefer pinning a released tag over a VCS repository entry.
Version alignment. 6.9.x and 7.x are parallel lines with parallel release notes. Check which PHP versions each supports against your app before composer require, and pin with a caret on the matching major. The release notes are auto-generated changelog links only — reading the diff between tags is the only way to know what changed.
No dependents, no stars, no issue tracker here. The GitHub repo is a mirror; issues live in ems-project/elasticms. If you hit a bug, search and report there, and expect to read source to work around it. That's an acceptable trade for a self-hosted localization pipeline, but it means: wrap it, test the round trip, and keep a fallback (a hand-rolled DOMDocument writer for plain-text-only cases is ~80 lines) in mind if the package stalls.
How can I help you explore Laravel packages today?