# 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 `[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 ```bash 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: ```php $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](https://spintax.net/docs/syntax.md) 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 | `[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](https://spintax.net/docs/plural-spintax.md) 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: ```php $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 _prototype_ whose templates this engine still accepts, 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](https://github.com/investblog/spintax-opencart)** — pins `spintax/core` and 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. ### Pairs naturally with LLMs Have a model draft the template _once_, then render unlimited variations on your own hardware — deterministic, free, and offline, with no per-generation API calls. The model handles creativity; the engine handles scale. Curious what the "no per-generation API calls" part is worth? See [what AI content actually costs](https://spintax.net/ai-content-costs.md). Coding agents get the condensed version: the [spintax-engines skill](https://spintax.net/.well-known/agent-skills/spintax-engines/SKILL.md) covers install and API for every engine, and templates can be validated over MCP at `https://spintax.net/mcp`. --- [View on Packagist](https://packagist.org/packages/spintax/core) [Source on GitHub](https://github.com/investblog/spintax-php) [The same engine in JavaScript](https://spintax.net/spintax-for-javascript.md) [Back to all guides](https://spintax.net/docs.md) --- Source: https://spintax.net/spintax-for-php/