spintax/core

Framework-agnostic spintax engine: enumerations, permutations, variables, conditionals, plural agreement, includes and a post-processing pipeline. Zero dependencies.

Maintainers

Package info

github.com/investblog/spintax-php

Homepage

pkg:composer/spintax/core

Transparency log

Statistics

Installs: 20

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v0.4.0 2026-07-26 07:27 UTC

This package is auto-updated.

Last update: 2026-07-26 07:31:02 UTC


README

CI Packagist License: MIT

A framework-agnostic spintax engine for PHP. Zero dependencies, PHP 8.0+.

This is the engine behind the Spintax WordPress plugin and @spintax/core on npm — extracted, so it can be used from any PHP application.

use Spintax\Core\Render\Pipeline;

echo (new Pipeline())->render(
    'Hola %name%, {tenemos|traemos} [<, > ofertas|novedades|precios] para ti. ' .
    '{plural 3: producto|productos}.',
    ['name' => 'Ana'],
    locale: 'es_ES',
);

// Every render is a different variant of the same message:
//   Hola Ana, tenemos ofertas, novedades, precios para ti. Productos.
//   Hola Ana, traemos novedades, precios, ofertas para ti. Productos.

Why another one

Most PHP spintax libraries parse {a|b|c} and stop there. That is a replacement primitive, and it is enough only until your copy needs to agree with itself:

{a|b|c} […] permutations %vars% conditionals plural agreement #include prose clean-up
typical spintax libs
spintax/core

Plural agreement is the one people discover late. %n% {товар|товара|товаров} picks a form at random — which is wrong for 1, wrong for 3, and wrong for 5. Russian, Ukrainian and Serbian content cannot be generated correctly without a number-gated form; this engine has one.

Three-form locales are ru, uk, be, sr, hr, bs. Everything else takes the EN-style 2-form rule — including pl, cs, sk, sl and bg, whose real rules differ and which are therefore not yet supported: they are accepted silently and bucketed wrongly, not rejected.

Install

composer require spintax/core

Syntax

construct meaning
{a|b|c} enumeration — pick one. Nests: {a|{b|c}}
[a|b|c] permutation — pick N, shuffle, join
[<minsize=2;maxsize=3;sep=", ";lastsep=" and "> a|b|c] configured permutation
%var% variable reference (case-insensitive)
#set %var% = value local variable, macro: the value is substituted at every reference and its brackets re-roll each time
#def %var% = value local variable, roll-once: the value is rendered a single time and every reference sees that same text
{?VAR?then|else} conditional — {?!VAR?…} inverts it
{plural <count>: one|few|many} plural agreement by grammatical bucket (RU/UK/BE + SR/HR/BS 3-form, EN-style 2-form)
#include "name" embed another template
/# … #/ comment, stripped from the output

Full reference with a live playground: spintax.net/docs/syntax.

Includes

Fetching a template is I/O, so it belongs to your application. Everything around the fetch — recursion, cycle detection, depth and fan-out budgets, and scope isolation — stays in the engine, so a naive host cannot hang itself:

$pipeline = new Pipeline(
    globals: ['brand' => 'Acme'],
    source: fn (string $name): ?string => $repository->rawTemplate($name),
);

echo $pipeline->render("Intro.\n#include \"footer\"");

A nested template inherits globals and runtime variables, but never the parent's #set locals — it defines its own. A circular include resolves to nothing rather than looping.

Validation

use Spintax\Core\Engine\Validator;

$result = (new Validator())->validate($template);
// $result['errors'] — line/column diagnostics: unbalanced brackets, bad plural arity, unknown includes…

Deterministic output

Inject the RNG and a render becomes reproducible — which is what makes the test suite below possible:

use Spintax\Core\Engine\Parser;

$always_first = new Parser(fn (int $min, int $max): int => $min);
$pipeline     = new Pipeline($always_first);

The parity contract

The same spintax syntax is implemented three times: here, in @spintax/core (TypeScript), and in the WordPress plugin. They are held together by a shared golden corpus — one set of JSON fixtures that every engine must reproduce.

This package's test suite is that corpus, run against the shipped Pipeline. Not a replica of it, not a subset: the same file the TypeScript engine is tested against. If a fixture goes red here, the engines have diverged, and that is the point.

git clone https://github.com/investblog/spintax-js ../spintax-js
SPINTAX_FIXTURES=../spintax-js/packages/conformance/fixtures vendor/bin/phpunit

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 it (the WordPress plugin applies wp_kses_post()). Those are host concerns, and an engine that guesses at them is an engine you have to fight.

Also available

License

MIT © 301st