A pure-PHP port of the Tailwind CSS v4 compiler. Generate utility-class CSS at
runtime — no Node.js, no build step, no npx tailwindcss.
composer require charged/tailwindphprequire 'vendor/autoload.php';
use TailwindPHP\tw;
$css = tw::generate([
'content' => '<div class="bg-blue-500 p-4 rounded-lg hover:bg-blue-600"></div>',
'css' => '@import "tailwindcss";',
'minify' => true,
]);
file_put_contents('public/app.css', $css);The compiler scans content for utility-class candidates, resolves them
against the Tailwind design system declared in css, and emits compiled
CSS. Output is byte-for-byte equivalent to the upstream JavaScript compiler
for the supported subset.
- Tailwind v4 utility classes (color, spacing, typography, layout, etc.)
- Arbitrary values:
bg-[#1da1f2],w-[42px] - Variants:
hover:,focus:,dark:,md:,[&>*]:, etc. @theme,@plugin,@source,@custom-variantat-rules- Bundled plugins:
@tailwindcss/forms,@tailwindcss/typography - Minification via a bundled
:where()-safe minifier
- WordPress themes that want Tailwind without a Node toolchain
- Drupal themes / modules with the same need
- Symfony / Laravel apps where adding a JS build step is overkill
- CI/CD pipelines that need to generate CSS server-side
- Email rendering pipelines (Tailwind classes inlined per message)
The package ships a small tw CLI for one-off compilations and CI:
# Compile a file, minify, write to disk
vendor/bin/tw --content=index.html --css=app.css --out=public/app.css --minify
# Pipe from stdin
cat index.html | vendor/bin/tw --content=- --minify > public/app.css
# Add @import search paths
vendor/bin/tw --content=index.html --import-path=./css/components --import-path=./css/themesRun vendor/bin/tw --help for the full option list. Exit code 2
signals a compilation error (bad CSS, unknown plugin) so CI scripts
can distinguish "user error" from "tool crash" (exit 1).
Pair with a thin integration layer that scans your templates for class candidates and writes the compiled output to a cache:
use TailwindPHP\tw;
$candidates = scan_my_templates_for_classes(); // your own scanner
$content = '<div class="' . implode(' ', $candidates) . '"></div>';
$css = tw::generate([
'content' => $content,
'css' => file_get_contents(__DIR__ . '/app.css'),
'minify' => true,
]);
file_put_contents(__DIR__ . '/cache/app.css', $css);tw::extractCandidates($html) scans class="..." / className="..."
attributes only. For Blade/Twig templates, cn(...) helper calls, or any
other source, bring your own scanner and feed the resulting class list in
via the content option above.
Real-world integrations:
- WordPress: charged-ui WP theme — block theme with on-demand compilation
- Drupal: charged_ui Drupal theme — same compiler, Drupal Render API integration
tw::generate() accepts a cache option that writes compiled CSS to disk
keyed by a hash of all inputs (content, css, importPaths, minify).
Subsequent calls with identical inputs serve the cached file instead of
recompiling. Writes are atomic (write-to-tmp + rename), so concurrent
requests can't corrupt entries.
tw::generate([
'content' => $html,
'css' => '@import "tailwindcss";',
'cache' => '/var/cache/tailwind', // or `true` for sys_get_temp_dir()
'cacheTtl' => 3600, // optional: seconds before recompile
'cacheMax' => 500, // optional: LRU-evict to keep ≤500 entries
]);
tw::clearCache('/var/cache/tailwind'); // wipe the cache directoryWithout cacheMax, the cache grows unbounded — set a sensible cap for
long-running apps with dynamic content.
When the compiler resolves @import from the filesystem, every
resolved path must lie under one of the directories you explicitly
list in importPaths (or the directory of the importing file).
A CSS input containing @import "/etc/passwd" or
@import "../../../etc/passwd" is silently refused — treated
identically to "file not found" — so a compiler accepting
user-supplied CSS can't be coerced into reading arbitrary files.
If you legitimately need cross-tree imports (e.g. compiling a CMS
theme that references vendor CSS outside the theme directory), pass
a callable resolver via importPaths:
tw::generate([
'content' => $html,
'css' => $css,
'importPaths' => function (?string $uri, ?string $fromFile): ?string {
// Return CSS content (or null to signal "not found").
return $myThemeLoader->fetch($uri);
},
]);A callable resolver bypasses the filesystem layer entirely; you own the security model for whatever it returns.
Pass plugin instances directly through the compile call:
use TailwindPHP\Plugin\PluginInterface;
use TailwindPHP\Plugin\PluginAPI;
final class BrandPlugin implements PluginInterface
{
public function getName(): string { return 'my/brand'; }
public function __invoke(PluginAPI $api, array $options = []): void
{
$api->addUtilities(['.brand-bg' => ['background' => 'oklch(72% .15 240)']]);
}
public function getThemeExtensions(array $options = []): array { return []; }
}
$css = tw::generate([
'content' => '<div class="brand-bg"></div>',
'css' => '@import "tailwindcss"; @plugin "my/brand";',
'plugins' => [new BrandPlugin()],
]);Plugins passed via the plugins option are scoped to that single
compile call — they don't leak into other compilations. The legacy
TailwindPHP\registerPlugin() helper still works but is deprecated;
prefer the per-compile DI form above.
For tooling, design-system explorers, or computed-style lookups:
$tw = tw::compile('@import "tailwindcss";');
$tw->properties('p-4'); // ['padding' => 'calc(var(--spacing) * 4)']
$tw->computedProperties('p-4'); // ['padding' => '1rem']
$tw->value('bg-blue-500'); // CSS variable form
$tw->computedValue('bg-blue-500'); // resolved colour
$tw->colors(); // ['blue-500' => 'oklch(...)', ...]
$tw->breakpoints(); // ['md' => '48rem', ...]tw::compile() returns a reusable instance — call it once and reuse for
many lookups instead of paying the parse cost per call. The static
tw::properties() / tw::value() / tw::colors() shortcuts internally
memoize compiler instances per CSS source, so calling them in sequence
in the same request is cheap.
PHP ports of the canonical JS companion libraries:
use function TailwindPHP\{cn, merge, variants};
cn('px-2 py-1', 'px-4'); // 'py-1 px-4' (tailwind-merge)
cn('btn', ['btn-lg' => $isLarge]); // conditional (clsx)
merge('text-red-500', 'text-blue-500'); // 'text-blue-500'
$button = variants([ // CVA-style variants
'base' => 'btn font-semibold',
'variants' => [
'size' => ['sm' => 'text-sm', 'lg' => 'text-lg'],
],
'defaultVariants' => ['size' => 'sm'],
]);
$button(['size' => 'lg', 'class' => 'mt-4']);Compiler errors throw typed exceptions under TailwindPHP\Exception\:
InvalidCssException— bad user CSS (malformed@source, empty@utility,@applyinside@keyframes, unbalanced brace expansion)CircularDependencyException—@applycycles (extendsInvalidCssException)UnknownPluginException—@plugin "name"not registered (extendsInvalidCssException)
All extend a common TailwindException base, so you can catch broadly or
specifically:
use TailwindPHP\Exception\{TailwindException, UnknownPluginException};
try {
$css = tw::generate([...]);
} catch (UnknownPluginException $e) {
// log and serve the last-known-good cached stylesheet
} catch (TailwindException $e) {
// any TailwindPHP compile error
}- PHP 8.2+
- No native extensions required
The compiler is procedural PHP. Typical compile times on a modern machine:
- 100 utilities: ~10ms
- 500 utilities: ~30ms
- 2000 utilities: ~90ms
Cache the output. Don't compile on every request.
composer install
composer test # phpunit
composer lint # phpcs (PSR-12 on the OO surface + tests)
composer analyse # phpstan level 5, with a baseline for the procedural portCI runs against PHP 8.2 / 8.3 / 8.4, plus PHPStan and a perf benchmark
suite on the 8.3 leg. The benchmark suite is gated behind the
TAILWINDPHP_BENCH=1 env var locally — set it if you want to run the
perf-regression tests outside CI:
TAILWINDPHP_BENCH=1 composer testsrc/theme.cache.php is a pre-parsed AST of resources/theme.css,
checked into the repo so PHP processes skip ~3 ms of CSS parsing on
cold start. Regenerate after editing resources/theme.css (typically
during an upstream Tailwind sync):
php bin/build-theme-cache.phpA test (tests/ThemeCacheTest.php) fails CI if the cache file goes
stale — you can't forget to regenerate it.
tests/fixtures/ holds end-to-end cases (input HTML + input CSS +
expected output). tests/FixtureParityTest.php
runs every fixture through the PHP compiler and asserts byte-identical
output.
Each expected.css declares its provenance in a header comment:
source=php-seeded— locks in current PHP behaviour as a regression guard. Useful, but doesn't verify upstream parity.source=upstream— generated by bin/regen-fixtures.sh, which runs the official@tailwindcss/cliJavaScript compiler against the inputs. This is the authoritative oracle.
Maintainers with Node available should periodically run the regen
script to promote php-seeded fixtures to upstream and catch any
drift. See tests/fixtures/README.md for
the methodology.
The PHP port of Tailwind v4's compiler originated as tailwindphp/tailwindphp by Dennis Josek. Substantial credit for the original implementation goes to Dennis — this package preserves his copyright as required by the MIT license.
Charged maintains this distribution to power the
Charged UI component ecosystem across Drupal,
WordPress, and other PHP CMS targets. It ships with cascade fixes, a
configurable forms plugin, and a :where()-safe minifier on top of the
original port.
Tailwind CSS itself is designed and maintained by Tailwind Labs, Inc. under the MIT license. This port carries the same license.
MIT. See LICENSE. Copyright (c) Dennis Josek (original port author) and Charged.