Building a Shared Icon and Font Library for Icinga Web

by | Jul 15, 2026

If you’ve copied the same icon font or the same LESS helper into two or three Icinga Web modules, you’ve hit the problem libraries exist to solve. This guide builds one from scratch: a small, self-contained icon and font pack that any number of modules can share, with no copy-pasting and no per-module registration step.

By the end, you’ll have a working library that uses every mechanism Icinga Web gives libraries: a composer.json manifest, PSR-4 autoloaded PHP, bundled JavaScript, bundled LESS, and individually addressable static files. Let’s get into it.

What is an Icinga Web library?

A library is a self-contained PHP project that Icinga Web picks up automatically from a configured library path (/usr/share/icinga-php by default, or a colon-separated list via ICINGAWEB_LIBDIR). Unlike a module, it has no on/off switch, no permissions, and no controllers. It’s simply present, and its classes and assets become available to every request the moment Icinga Web boots.

That’s exactly the shape an icon pack wants. You don’t want administrators enabling or disabling your icons per module, you don’t need a configuration page, and you definitely don’t want three different modules shipping three slightly different copies of the same webfont. You want one place that owns the icon markup, the font files, and the stylesheet and any module that needs an icon just asks for it.

If your icon pack ever grew a settings page (“let editors upload custom icons,” say), that feature would belong in a module that depends on this library, not in the library itself. Libraries ship code and assets to be consumed; they don’t have user-facing lifecycles.

Step 1: Lay out the directory

Everything lives under one top-level folder, which is what you’ll eventually drop into your library path:

brand-icons/
├── composer.json
├── src/
│   └── IconRegistry.php
└── asset/
    ├── js/
    │   └── icon-registry.js
    ├── css/
    │   └── icons.less
    └── static/
        ├── font/
        │   └── acme/
        │       ├── brand-icons.woff2
        │       └── brand-icons.woff
        └── svg/
            └── heart.svg

Nothing here is optional except the three asset/* subfolders — you could ship a library with no assets at all, or only asset/static/. You’re using all three on purpose, to see how each one behaves.

Step 2: Declare the library with composer.json

This file is what turns your folder into a registered library. Without it, Icinga Web won’t even look at the directory’s contents. Create it at the top level:

{
    "name": "acme/brand-icons",
    "description": "A shared icon and font asset pack for Icinga Web modules.",
    "type": "project",
    "license": "MIT",
    "version": "1.0.0",
    "require": {
        "php": ">=8.2"
    },
    "autoload": {
        "psr-4": {
            "Acme\\BrandIcons\\": "src/"
        }
    }
}

A few things worth calling out, since each field earns its place:

  • name is mandatory and is the only way another module can find this library at runtime, via Icinga\Application\Libraries::get('acme/brand-icons') or ::has('acme/brand-icons'). It also becomes the URL prefix for your static assets, so pick it once and don’t rename it later.
  • version is technically optional, but you’re setting it explicitly so modules can gate on a minimum release. If you omit it, Icinga Web falls back to a VERSION file in the library root, and only reports an empty string if neither exists. Useful to know if you ever package this for distribution instead of via Composer.
  • require doesn’t install anything by itself (that’s Composer’s job when you run composer install), but it’s readable at runtime through the library’s isRequired() check, which is how, for example, one library can confirm another actually brings in a component it depends on.
  • autoload is standard Composer PSR-4. This is what makes src/IconRegistry.php reachable as Acme\BrandIcons\IconRegistry once the library’s autoloader is included.

Run composer install inside the folder once you’ve got this in place, even with no third-party dependencies, this is what generates vendor/autoload.php, which Icinga Web require_once‘s on boot. Skip this step, and your class metadata will be readable, but the class itself won’t be. You’ll see Class not found at runtime.

Step 3: Ship the reusable PHP

This is the part a module would never have needed a library for if it only had to happen once but you want every module that needs an icon to render it identically, so it belongs in shared code:

<?php

namespace Acme\BrandIcons;

/**
 * Maps logical icon names to the CSS classes shipped by this library's
 * asset/css/icons.less stylesheet. Any module that depends on this
 * library can pull this class in through Icinga Web's autoloader —
 * it's available regardless of which module is currently handling
 * the request.
 */
class IconRegistry
{
    /** @var array<string, string> */
    private static array $icons = [
        'heart'   => 'brand-icon-heart',
        'warning' => 'brand-icon-warning',
        'check'   => 'brand-icon-check',
    ];

    /**
     * Render the markup for a given logical icon name.
     */
    public static function html(string $name, string $extraClass = ''): string
    {
        $class = self::$icons[$name] ?? 'brand-icon-missing';

        return sprintf(
            '<i class="brand-icon %s %s" aria-hidden="true"></i>',
            htmlspecialchars($class, ENT_QUOTES),
            htmlspecialchars($extraClass, ENT_QUOTES)
        );
    }

    /**
     * All logical names this pack currently knows about — handy for
     * populating a <select> in a consuming module's configuration form.
     *
     * @return string[]
     */
    public static function names(): array
    {
        return array_keys(self::$icons);
    }
}

Notice the CSS class prefix: brand-icon, not the bare icon you’ll see in some examples elsewhere. Icinga Web itself already uses a generic .icon class for its own font icons, so reusing that name here would silently collide with core styling in a production install. Prefixing with your own library’s identity avoids that. The same reasoning that gave you the acme/ Composer vendor namespace applies to CSS class names too.

Notice also what’s not here: no controller, no route, no hook registration. A library can define classes other code extends, but it can’t register itself as a hook implementation – that step, if you ever need it, has to happen in a module that consumes this library.

Step 4: Add the font and static files

Anything the browser should request as its own file: fonts, images, anything that isn’t bundled, goes under asset/static/. Drop your webfont in:

asset/static/font/acme/brand-icons.woff2
asset/static/font/acme/brand-icons.woff

and an SVG fallback in:

asset/static/svg/heart.svg

You don’t need to register these anywhere. Icinga Web serves everything under asset/static/ at a predictable, cacheable URL:

/lib/acme/brand-icons/font/acme/brand-icons.woff2
/lib/acme/brand-icons/svg/heart.svg

That acme/brand-icons segment is exactly the name you wrote into composer.json — which is precisely why getting that field right early matters: it’s now baked into every static asset URL you reference from LESS or markup.

Note: only asset/static/ gets this stable per-file URL. Don’t try to resolve /lib/acme/brand-icons/js/… or /lib/acme/brand-icons/css/… directly — those two are bundled into Icinga Web’s application-wide JS and CSS instead, which is what the next two steps cover.

Step 5: Style the icons with LESS

Files in asset/css/ join Icinga Web’s own LESS compilation, so you get access to the active theme’s variables and mixins — and you can build the font-face declaration on top of the static asset path from the previous step:

// asset/css/icons.less

@brandIconFont: "../lib/acme/brand-icons/font/acme";

@font-face {
    font-family: "BrandIcons";
    src: url("@{brandIconFont}/brand-icons.woff2") format("woff2"),
         url("@{brandIconFont}/brand-icons.woff") format("woff");
    font-weight: normal;
    font-style: normal;
}

.brand-icon {
    font-family: "BrandIcons";
    display: inline-block;
    color: @text-color; // an Icinga Web theme variable, free to use here
}

.brand-icon-heart::before   { content: "\e001"; }
.brand-icon-warning::before { content: "\e002"; }
.brand-icon-check::before   { content: "\e003"; }
.brand-icon-missing::before { content: "\e000"; }

This is the same pattern Icinga Web’s own LESS uses for icinga-php-library‘s font-awesome assets — a variable holding the /lib/<name>/… prefix, reused everywhere the stylesheet needs to point at a static file. .less files here get compiled alongside Icinga Web’s sources; plain .css files would simply be concatenated as-is. Either way, there’s no separate request for your stylesheet — it’s part of the one application-wide CSS bundle.

Step 6: Add a small JS helper

asset/js/ files get bundled into the main Icinga Web JS file. They’re never served as standalone <script> tags. Rather than hand-rolling a DOM loop, reuse the iterator helper icinga-php-library already ships at asset/js/iterator.js, available to every other library and module as the AMD module icinga/icinga-php-library/iterator. It gives you filter, find, and map over anything iterable — exactly what you want for picking out icon elements on the page:

// asset/js/icon-registry.js

define(['icinga/icinga-php-library/iterator'], function (Iter) {

    'use strict';

    /**
     * Tiny client-side helper for the icon pack. Titles every rendered
     * icon with its logical name, so hovering over one in a running
     * Icinga Web instance shows you which name to pass to
     * IconRegistry::html() on the PHP side.
     */
    return {
        initialize: function () {
            var icons = document.querySelectorAll('.brand-icon');

            var named = Iter.filter(icons, function (el) {
                return /brand-icon-([a-z]+)/.test(el.className);
            });

            for (const el of named) {
                var match = el.className.match(/brand-icon-([a-z]+)/);
                el.setAttribute('title', 'icon: ' + match[1]);
            }
        }
    };

});

Two mechanics worth knowing before you write more of these:

  • Icinga Web wraps every library’s define() calls so they’re namespaced under your library’s name. That’s what keeps your library’s icon-registry.js from colliding with some other library’s file of the same name. A dependency on another library’s module, like the iterator one above, is addressed the same way: <library name>/<path inside asset/js, without the .js extension>.
  • If you want to ship a minified build, just place icon-registry.min.js next to icon-registry.js. Icinga Web looks for the .min.js counterpart when a minified response is requested and skips the .min.js file during its normal scan, you don’t register the pairing anywhere, it’s purely by filename.

Step 7: Install and deploy the library

Inside brand-icons/, run:

composer install

This generates vendor/autoload.php — required even though require only lists php, since it’s what wires up your own Acme\BrandIcons\ namespace. Then place the whole folder under your library path:

cp -r brand-icons /usr/share/icinga-php/brand-icons

Or, for development, symlink it there instead so you can keep editing in place. Either way, the folder name on disk doesn’t matter — Icinga Web identifies the library by the name field inside composer.json, not by the directory it happens to sit in.

Step 8: Use it from a module

Now the payoff. From any module’s controller or view script:

use Acme\BrandIcons\IconRegistry;

echo IconRegistry::html('warning', 'text-warning');

No require, no use path juggling beyond the namespace. The class is already autoloaded because the library was registered at application startup. If a module wants to confirm the library is actually present (and optionally check what it declares in require) before relying on it, that’s what the runtime lookup is for:

use Icinga\Application\Libraries;

if (Libraries::has('acme/brand-icons')) {
    $iconLibrary = Libraries::get('acme/brand-icons');
    // e.g. $iconLibrary->isRequired('some/other-package') to check a transitive dependency
}

And in the module’s own LESS, if it ever needs to point at one of the pack’s static files directly (say, the SVG fallback), it reaches for the same /lib/<name>/… URL scheme:

background-image: url("../lib/acme/brand-icons/svg/heart.svg");

Library or module: how to decide

Worth sitting with for a second, now that you’ve built one: everything you just shipped, the icon markup helper, the font, the stylesheet, the JS, has no lifecycle. It’s not enabled or disabled by an administrator, it doesn’t appear in a navigation menu, and it doesn’t own any URL beyond the static asset endpoint Icinga Web exposes on its behalf. It exists purely to be depended on.

That’s the dividing line to keep in mind going forward: reach for a module the moment you need UI presence, independent activation, permissions, or routes. Reach for a library when what you’re building is code or assets meant to be shared, which, if you’ve got more than one module reaching for the same icon, is exactly where you’ll land again.

You May Also Like…

 

Subscribe to our Newsletter

A monthly digest of the latest Icinga news, releases, articles and community topics.