Spintax for PHP
The engine that renders spintax inside the WordPress plugin now ships on its own: spintax/core, MIT-licensed, zero runtime dependencies, PHP 8.0+. No WordPress, no framework, no assumptions about where your templates live.
What it is, and what most PHP spintax libraries are not
Search Packagist for spintax and you will find parsers for {a|b|c}. That is the replacement primitive, and it is genuinely enough — right up to the moment your copy has to agree with itself.
A sentence like "we support %n% languages" needs the noun to match the number. A phrase that appears twice needs to still be the same phrase. A block that should only appear for paying customers needs a condition, not a coin flip. spintax/core ships those as first-class constructs rather than leaving them to string concatenation in your controller.
| Construct | Typical PHP spintax library | spintax/core |
|---|---|---|
Enumeration {a|b|c} | yes | yes |
Permutation [<config>a|b|c] | rarely | yes, with separators |
Variables %name% | sometimes | three scopes, defined precedence |
Conditionals {?VAR?…} | no | yes |
| Plural agreement | no | yes, locale-aware |
Includes #include | no | yes, with cycle and depth guards |
| Post-processing | no | spacing, capitalisation, URL shielding |
Install
composer require spintax/core
PHP 8.0 or newer, ext-mbstring, and nothing else. No framework integration to configure, no service provider to register.
Quick start
use Spintax\Core\Render\Pipeline;
$pipeline = new Pipeline();
echo $pipeline->render(
'{Welcome to|Meet} %product% — supports %n% {plural %n%: language|languages}.',
['product' => 'Acme', 'n' => '3'],
);
// → "Meet Acme — supports 3 languages."
// → "Welcome to Acme — supports 3 languages." (the enumeration is a fresh
// pick on every call; see Determinism below to pin it)
One object, one call. The engine is instance-based rather than a static facade, because the things you configure once — where #include fetches from, which globals every template sees, the random source — belong to the pipeline's lifetime, not to each render.
The API
Pipeline is the whole public surface for normal use. Parser, Validator, Plurals and Conditionals are exposed underneath it for tooling that needs a single stage rather than the whole run.
| Call | What it does |
|---|---|
Pipeline::render($raw, $runtime_vars, $context, $locale, $post_process) | Run the full pipeline to a string. Lenient — a malformed construct degrades visibly rather than throwing. |
Validator::validate($template, $known_slugs, $global_var_names, $locale) | Return ['errors' => […], 'warnings' => […]], each with a message, line and column. Valid ⇔ no errors. |
Plurals::apply($text, $lang, $options) | The plural pass alone, and it is strict by default: a broken construct throws. Pass ['lenient' => true] to degrade instead. Pipeline opts into lenient for you, which is why render() never throws on template content. |
Conditionals::apply($template, $variables) | The conditional pass alone. |
Parser::* | Individual stages: comments, directive extraction, variable expansion, enumerations, permutations, post-process, includes. |
Determinism
There is no seed parameter. Instead you inject the random source, which is the same idea one layer down:
$deterministic = new Pipeline(new Parser(fn(int $min, int $max) => $min));
Any callable of the shape fn(int $min, int $max): int works, so a seeded PRNG, a fixed pick, or a recorded sequence from your test fixtures all drop in without the engine knowing which.
The same syntax, everywhere
The syntax reference applies verbatim — it is the same language the WordPress plugin, the JavaScript package and the playground speak.
| Construct | Example | Meaning |
|---|---|---|
| Enumeration | {a|b|c} | pick one (nestable) |
| Permutation | [<minsize=2;sep=", ">a|b|c] | pick N, shuffle, join |
| Variable | %name% | substitute a value |
| Local set | #set %v% = value | a macro — re-rolled at every reference |
| Local def | #def %v% = value | resolved once per render, held everywhere |
| Conditional | {?VAR?then|else} | value-driven branch |
| Plural | {plural %n%: one|few|many} | agreement by locale |
| Include | #include "slug" | embed another template |
| Comment | /# … #/ | stripped before rendering |
Plural agreement — the one people discover late
Written as an enumeration, %n% {товар|товара|товаров} picks a form at random. It is wrong for 1, wrong for 3 and wrong for 5, and it reads fine in whichever preview you happened to look at. The plural construct resolves the form from the number instead:
{plural %n%: товар|товара|товаров}
Three forms for ru, uk, be, sr, hr, bs; two for English-style locales. The plural guide covers the bucket rules and the edge cases.
Know what is not implemented. Polish, Czech, Slovak, Slovenian and Bulgarian have plural systems of their own, and the engine implements none of them — it does not reject them either. A pl template is bucketed by the English two-form rule, which is not Polish grammar. The package's own README says so in as many words; treat output in those locales as unverified.
Includes: your I/O, the engine's safety
Fetching a template is I/O — a query, a file read, a cache lookup — so it belongs to your application. Everything around the fetch stays in the engine:
$pipeline = new Pipeline(
source: fn(string $slug): ?string => $repository->findBySlug($slug),
);
Recursion, cycle detection, a depth ceiling and a total fan-out budget are enforced regardless of what your resolver does. A circular include resolves to nothing rather than looping. That split is deliberate: a naive host should not be able to hang itself on a template.
Deliberately not here
Caching, template storage, settings — and output sanitisation. render() returns pre-sanitise text. A host emitting HTML must run its own sanitiser over the result; the WordPress plugin applies wp_kses_post(), and yours should apply whatever your context requires.
This is worth stating plainly rather than burying: an engine that guesses at your escaping rules is an engine you end up fighting. It renders text and hands it back.
Parity is enforced, not intended
A shared golden corpus of language-neutral fixtures — (template, context, locale, seed) → expected — is the contract between the implementations. This package's test suite is that corpus, checked out from the JavaScript repo rather than vendored, because a copy would drift and a drifting contract is not a contract.
It runs in both directions: a fixture cannot land in the corpus unless the engines it binds already satisfy it, and an engine change cannot land unless the corpus still passes. The fixtures run against the shipped Pipeline, not a test-local replica of it.
What is not gated: random selection. Seeded renders are reproducible within one engine; identical random sequences across engines is a deliberate non-goal. You get a valid pick either way, just not the same one.
Where the two packages differ on purpose
Same language, same semantics, different ergonomics. @spintax/core ships a functional facade with a reusable AST and a tooling layer (analyze, neutralize); the PHP package ships the pipeline plus its stage primitives, and adds a strict plural mode that throws — useful behind an editor. Diagnostics differ in shape too. Both report a message with a line and column; only the JavaScript one carries a stable, parity-gated code alongside it, which is what lets the playground map a diagnostic to a translated string. The PHP validator has no code field, so a host that wants to branch on a specific problem matches on structure rather than on an identifier. What is gated across both engines is the verdict — whether a template is valid — not the wording used to explain it.
Why MIT, when the plugin is GPL
This is an extraction, not a rewrite, and the seam was already cut in the original code: the plugin's renderer had a pure stage orchestrator on one side and a WordPress adapter on the other, and the engine files carried no WordPress references to begin with. Moving them out was close to pure deletion: a constant guard, some linter pragmas, and one WordPress JSON helper swapped for the standard-library one.
The same author holds copyright in that code, which is what makes relicensing it possible at all. The engine is an original implementation: GTW is a syntax this engine is compatible with, not a codebase anything here descends from, and the conditional, plural and post-processing layers were written from scratch. MIT is GPL-compatible, so the GPL plugin can consume the MIT package while the reverse would not hold.
One engine, many surfaces
- OpenCart SEO — pins
spintax/coreand unpacks it into the extension. The real downstream consumer today. - The WordPress plugin — the same engine code, though it still carries its own copy rather than depending on the package. The cross-engine corpus already closes the drift risk that a dependency would; consolidating is a cleanup, not a fix.
- Your application — a Laravel job, a Symfony command, a plain script. The package has no opinion about which.