PHP helper

Deck is a stylesheet, not a PHP library, so this class does three things: tell you where the assets are, emit the tags, and version the URLs so a browser picks up a new build. None of it is required — if you would rather write the <link> tag yourself, write the <link> tag yourself, and nothing else in Deck will notice.

Installing

composer require echodial/deck

PHP 8.1 or newer, and no runtime dependencies. It is framework-agnostic on purpose — no container, no service provider, no facade — so the same class works in Keel, in Laravel, in Symfony, in WordPress, and in a single index.php.

A browser cannot read vendor/, so the assets have to be copied into a public directory, and Composer will not do it for you: it never runs scripts from a package you install, only the ones in your own composer.json. Add the publisher there:

"scripts": {
  "post-install-cmd": ["EchoDial\\Deck\\Installer::postInstall"],
  "post-update-cmd": ["EchoDial\\Deck\\Installer::postInstall"],
  "deck-publish": "EchoDial\\Deck\\Installer::publish"
},
"extra": {
  "deck": {
    "publish-to": "public/assets/deck",
    "auto-publish": true
  }
}

composer require, composer install and composer update then publish, and composer deck-publish publishes on demand. extra.deck is read from your composer.json, never from Deck's.

composer deck-publish
composer deck-publish -- public/static/deck
composer deck-publish -- --link          # symlink where the platform allows it

publish-to defaults to public/assets/deck. Change it if your document root is not public/: on cPanel and Helm it is public_html/. With auto-publish left out or false, the install and update hooks print a reminder instead of copying. The install page covers the two ways to get the files without any scripts.

The installer skips a file whose contents are unchanged rather than recopying it. That is deliberate: recopying rewrites the mtime, and the mtime is what Deck::asset() uses to bust the cache — so a churning deploy would invalidate every asset URL on every release for no reason. Symlinks fall back to a copy where the platform refuses them, which is Windows without developer mode and some containers.

Configuration

Ten options, all with defaults that work. The two worth setting explicitly are base, which nothing can guess, and root, which everything to do with cache busting depends on.

Configuration options and their defaults
OptionDefaultWhat it does
base '/assets/deck' The public URL prefix the assets are served from. Everything else is built on this.
root null The filesystem path that base hangs off, used to read mtimes and to check which files exist. Falls back to $_SERVER["DOCUMENT_ROOT"].
minify null true or false to force the choice. null decides by looking for deck.min.css on disk.
bust true Append ?v= to asset URLs, from the file mtime where the file can be found and from the package version otherwise.
extras true Emit the deck-extras.js tag.
adapters false Emit the deck-adapters.js tag. Off by default because it does nothing unless one of the optional libraries is on the page.
bundle false Emit one combined script instead of two or three tags.
defer true Put defer on the script tags.
hue null An OKLCH hue angle emitted as an inline :root style from head().
theme null 'light' or 'dark', emitted as data-theme by htmlAttributes(). null follows the operating system.

root is the one that catches people. It is the filesystem path that base is relative to — usually your document root, not the directory the class is in. Get it wrong and nothing breaks visibly: the tags still render, the URLs still work, and ?v= quietly falls back to the package version, so a deploy stops busting caches and nobody finds out until a stale stylesheet is on a customer's screen.

Methods

All static, all on EchoDial\Deck\Deck. Deck::VERSION is the package version as a string.

Deck::configure(array $options): void

Merges options into the configuration. Call it once, early — before anything else on this page, since every other method reads from it.

Parameters for Deck::configure(array $options): void
ParameterTypeMeaning
$options array Any of the keys in the table above. Anything you leave out keeps its default.
Returns
Nothing.
When what it needs is missing
The configuration is static, so it is per-request and shared across the whole request. Calling it twice merges rather than replaces, which means you cannot unset a key by omitting it — pass the default back explicitly. There is no validation: an unknown key is stored and ignored, so a typo is silent.
use EchoDial\Deck\Deck;

Deck::configure([
    'base'   => '/assets/deck',
    'root'   => __DIR__,        // this file's directory is the web root
    'extras' => true,
]);

Deck::css(): string

The stylesheet <link> tag on its own, for a layout that wants to place the scripts somewhere else.

Returns
One <link rel="stylesheet"> tag with the URL escaped.
When what it needs is missing
Which file it links is stylesheet()'s decision. The URL carries a cache-busting query where one could be worked out.
<?= Deck::css() ?>
<!-- <link rel="stylesheet" href="/assets/deck/deck.min.css?v=1725900000"> -->

Deck::js(): string

The script tags, in the right order, honouring the extras, adapters, bundle and defer options.

Returns
One to three <script> tags separated by newlines.
When what it needs is missing
With bundle on, it looks for deck.bundle.min.js on disk and falls back to the unminified bundle when it is not there — so a project that only published the readable build still gets a working tag rather than a 404. That check needs root to be right.
Deck::configure(['bundle' => true]);
echo Deck::js();
// <script src="/assets/deck/deck.bundle.min.js?v=…" defer></script>

Deck::asset(string $file): string

The public URL for one file under base, with a cache-busting query where one can be worked out. This is what everything else here calls.

Parameters for Deck::asset(string $file): string
ParameterTypeMeaning
$file string A filename such as 'deck-icons.svg'.
Returns
The URL, with ?v= appended unless bust is off.
When what it needs is missing
The version comes from the file's mtime, which requires finding it on disk: root if you set it, otherwise $_SERVER["DOCUMENT_ROOT"]. On a CLI request, or behind a server that does not set that, or where the path does not resolve, it falls back to the package version instead — so the URL is still stable and still works, but a deploy that does not change the version will not bust the cache. If cache busting matters, set root explicitly.
Deck::asset('deck-icons.svg');
// '/assets/deck/deck-icons.svg?v=1725900000'

Deck::stylesheet(): string

Which stylesheet filename Deck should link.

Returns
'deck.min.css' or 'deck.css'.
When what it needs is missing
With minify at its default of null, it checks whether deck.min.css exists under root. Where root cannot be worked out, that check fails and it returns the unminified name — correct, but larger than you wanted. Set minify to true in production and the check is skipped.
Deck::stylesheet();   // 'deck.min.css' in production

Deck::htmlAttributes(?string $lang = null, ?string $dir = null): string

Attributes for the <html> tag: language, direction, and the configured theme.

Parameters for Deck::htmlAttributes(?string $lang = null, ?string $dir = null): string
ParameterTypeMeaning
$lang ?string The document language, such as 'en'.
$dir ?string 'ltr' or 'rtl'.
Returns
A space-separated attribute string with no leading space, ready to drop straight into the tag. Empty when nothing is configured.
When what it needs is missing
Only emits data-theme when the theme option is not null. Leaving it null is usually right: the operating system decides, and the script restores a saved choice before first paint.
<html <?= Deck::htmlAttributes(lang: 'en', dir: 'ltr') ?>>

Deck::theme(?int $hue = null, ?string $mode = null, ?float $chroma = null): string

The per-tenant theming this whole framework was built for: one inline style on the <html> tag, no rebuild, no second stylesheet, and the right colours in the very first paint.

Parameters for Deck::theme(?int $hue = null, ?string $mode = null, ?float $chroma = null): string
ParameterTypeMeaning
$hue ?int An OKLCH hue angle, 0 to 360, emitted as --hue-brand.
$mode ?string 'light' or 'dark', emitted as data-theme.
$chroma ?float Emitted as --chroma-brand. Lower is more muted. Trailing zeros are trimmed.
Returns
A style attribute, a data-theme attribute, both, or an empty string. Values are escaped.
When what it needs is missing
Every argument is optional and a null one is simply omitted, so a tenant with no brand colour and a user with no theme preference produce an empty string and the defaults apply. Because it is server-rendered there is no flash of the wrong brand colour, which is the reason to prefer it over Deck.hue() in JavaScript.
<html <?= Deck::htmlAttributes(lang: 'en') ?>
      <?= Deck::theme(hue: $tenant->hue, mode: $user->theme) ?>>
<!-- <html lang="en" style="--hue-brand:265" data-theme="dark"> -->

Deck::icon(string $name, string $class = "icon", ?string $label = null): string

One <svg><use> pointing at Deck's sprite.

Parameters for Deck::icon(string $name, string $class = "icon", ?string $label = null): string
ParameterTypeMeaning
$name string The icon id, such as 'check-circle'.
$class string Classes for the svg. icon-sm, icon-lg and icon-fill are the usual additions.
$label ?string An accessible name. Omit it for a decorative icon.
Returns
An SVG element as a string. The name, the class and the sprite URL are all escaped.
When what it needs is missing
With no $label the icon gets aria-hidden="true", which is right when there is text beside it and wrong when the icon is the only content — an icon-only button with no label is announced as "button". Pass $label there, or put the words in a .sr-only span. A name that is not in the sprite renders an empty svg rather than an error, so check the spelling if an icon simply does not appear.
<?= Deck::icon('check-circle', 'icon icon-lg', 'Paid') ?>
<?= Deck::icon('chevron-down') ?>   <!-- decorative -->

Deck::config(string $key, mixed $default = null): mixed

Reads one configuration value back.

Parameters for Deck::config(string $key, mixed $default = null): mixed
ParameterTypeMeaning
$key string The option name.
$default mixed Returned when the key was never set.
Returns
The value, or the default.
When what it needs is missing
A key that was never set and has no default returns null, which is indistinguishable from an option genuinely set to null — hue and theme both default to null. Pass a sentinel default if you need to tell those apart.
Deck::config('base');            // '/assets/deck'
Deck::config('missing', 'fallback');

Deck::distPath(): string

The absolute filesystem path to Deck's own dist directory inside vendor/. For a build step or a deploy script that wants to copy the files somewhere itself.

Returns
An absolute path.
When what it needs is missing
It is the path inside the installed package, so a browser cannot read it — the files still have to be published into a public directory. That is what the Composer installer below does.
copy(Deck::distPath() . '/deck.min.css', __DIR__ . '/public/deck.min.css');

All together

A complete page. The tenant's brand colour and the user's theme come out of the database and land in the first byte of HTML, so there is no flash of the wrong colour and no second request.

<?php
use EchoDial\Deck\Deck;

Deck::configure([
    'base'   => '/assets/deck',
    'root'   => __DIR__,
    'minify' => true,
]);
?>
<!doctype html>
<html <?= Deck::htmlAttributes(lang: 'en') ?> <?= Deck::theme(hue: $tenant->hue, mode: $user->theme) ?>>
<head>
<meta charset="utf-8">
<title><?= htmlspecialchars($tenant->name) ?></title>
<?= Deck::head() ?>
</head>
<body>
  <header class="sticky-top">
    <div class="container">
      <nav class="navbar">
        <a class="navbar-brand" href="/"><?= Deck::icon('grid', 'icon icon-lg') ?> <?= htmlspecialchars($tenant->name) ?></a>
        <button class="btn btn-icon btn-ghost push" data-deck-theme aria-label="Switch theme">
          <?= Deck::icon('moon') ?>
        </button>
      </nav>
    </div>
  </header>
  <main class="container section stack stack-6">…</main>
</body>
</html>

There is a runnable version of this in the repository at public_html/php-helper.php. It deliberately requires the class by path rather than through the autoloader, so it works in a bare PHP site with nothing installed.

Things worth knowing

  • Everything returns a string. Nothing echoes, nothing buffers, and nothing touches global state beyond the static configuration. The methods are safe to call in a template.
  • Markup is not escaped; values are. The return values are HTML and are meant to be echoed raw. The parts that come from you — the icon name, the class, the theme, the URLs — are escaped inside.
  • The configuration is static. Per request, shared across the request. In a long-running worker — Swoole, RoadRunner, Octane — it survives between requests, so configure it once at boot rather than per request, or reset it explicitly.
  • It does not read the stylesheet. Nothing here parses CSS or knows what classes exist. If you want that inventory in PHP, read dist/api.json — the documentation site does exactly that.