Search by

wexample / symfony-template

weeger

Various helpers for Symfony templating

Package info

github.com/wexample/symfony-template

pkg:composer/wexample/symfony-template

Statistics

Installs: 531

Dependents: 4

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-09-24 15:26 UTC

README

Version: 2.0.0

wexample/symfony-template is a Symfony bundle that adds templating helpers to a Twig-based application: an icon() / icon_source() / icon_list() set that resolves names like fa:solid/coffee across Font Awesome, Material Icons and Phosphor — as a tag or as inlined, class-annotated SVG; the whole of the suite's markdown, which is a markdown filter and a markdown_file() function rendering Github-flavoured markdown, tables included, with the front matter and section handling that sits behind them; and a system_version() function reading the project's version.txt. Alongside them, Wexample\SymfonyTemplate\Helper\TemplateHelper handles the string work around views: stripping the .html.twig extension, trimming the @ namespace prefix, turning a controller namespace into snake-cased path parts.

It is meant for Symfony developers building on the Wexample suite — it requires wexample/symfony-helpers and registers itself through WexampleSymfonyTemplateBundle, which also exposes the bundle's assets/ directory to the front-end loader.

Table of Contents

Architecture

The package is a Symfony bundle with no controllers, no entities and no configuration tree: it registers a handful of Twig extensions, and those extensions delegate to services. Four layers, in the order a request crosses them — bundle → container → Twig extension → service → icon manager or markdown converter — plus two static helpers that sit outside the container entirely.

Bundle and container wiring

src/WexampleSymfonyTemplateBundle.php extends AbstractBundle from wexample/symfony-helpers and implements LoaderBundleInterface. Its only body is the front-asset declaration:

public static function getLoaderFrontPaths(): array
{
    return [
        BundleHelper::getBundleCssAlias(static::class) => __DIR__.'/../assets/',
    ];
}

getBundleCssAlias() kebab-cases the first two namespace segments, so the alias is @wexample/symfony-template pointing at assets.

Service registration goes through src/DependencyInjection/WexampleSymfonyTemplateExtension.php, whose load() is a single call to $this->loadConfig(__DIR__, $container). The parent resolves __DIR__.'/../Resources/config' and loads services.yaml — which is why src/Resources/config/services.yaml lives under Resources/, not at the package root. That file registers two directories and nothing else:

Wexample\SymfonyTemplate\:
    resource: '../../{Service,Twig}'
    tags: ['controller.service_arguments']

src/Helper/, src/Class/ and src/Enum/ are deliberately absent from that glob: TemplateHelper and MarkdownHelper are all static methods and are never instantiated, MarkdownDocument is a value object built from a string, and MarkdownFlavor is an enum.

The Twig layer

Three extensions in src/Twig, all extending AbstractExtension from wexample/symfony-helpers for the FUNCTION_OPTION_IS_SAFE / FUNCTION_OPTION_NEEDS_ENVIRONMENT constants.

  • src/Twig/IconExtension.php exposes icon(), icon_source() and icon_list(). It owns no logic — each method forwards to IconService with the same arguments. Note that the function name for the first is built as VariableHelper::ICON . '_source', and the callable as VariableHelper::ICON . 'Source'.
  • src/Twig/MarkdownExtension.php exposes the markdown filter and the markdown_file() function, both forwarding to MarkdownService. The one thing it owns is path resolution — see below.
  • src/Twig/SystemExtension.php exposes system_version(), which reads $this->kernel->getProjectDir() . '/' . $versionFile (default version.txt) and returns null when the file is absent. The file belongs to the host application, not to this package.

Markdown

The whole of markdown in the Wexample suite is here, and deliberately in one package. Before this, three implementations shared the subject — a markdown filter in this package, a markdown_file() function in wexample/symfony-content that read a file without converting it, and the third-party markdown_to_html filter of twig/markdown-extra — none of which rendered a table. The two others are gone, and symfony-content no longer declares a markdown dependency.

Four files, from the most general down:

  • src/Enum/MarkdownFlavor.php names the dialect and builds the environment for it — the extensions each flavour loads, before any converter seals it. GITHUB is the default, and default() says so in one place. The reason is tables: CommonMark has none, so a pipe table renders as rows of vertical bars. Github's flavour is a superset — tables, strikethrough, task lists, autolinks — so moving the default to it is a correction for every existing caller, and COMMON_MARK stays reachable for whoever needs a pipe to be a pipe. fromNameOrDefault() takes the string a template typed and falls back rather than raising.
  • src/Helper/MarkdownHelper.php is the pure string work: splitFrontMatter(), withoutComments(), withoutSection() and withoutSections(). It walks lines and tracks fenced code rather than running regular expressions over the whole text, and that is the point of it — a shell block is full of lines opening on #, a documentation about markdown is full of lines opening on ---, and read without the fence in mind a code block gets taken for headings and a page loses its second half. FRONT_MATTER_MAX_LINES stops an opening --- that never closes from being read as a front matter.
  • src/Class/MarkdownDocument.php is the immutable pair of a parsed front matter and a body. fromString() splits and parses; withoutComments() and withoutSections() give back another document; text() and texts() read one field as a line of text or as a list, trimming and treating a field left empty as a field never written. A front matter yaml cannot parse comes out as no fields at all — one malformed document is a line to report in a list of a hundred and fifty, not a page that refuses to open.
  • src/Service/MarkdownService.php is the container-visible entry point and the only piece that touches a disk. toHtml() renders, caching one converter per flavour because assembling a converter assembles its whole extension set. Before a converter is built, configureEnvironment() is called on its environment: empty here, it is where a child registers its own renderers — symfony-design-system draws tables through data_table() that way, as a second service rather than a replacement, so that a mail keeps plain html. parse() and read() build a document from a string or a file. readFrontMatter() exists next to read() for one reason: a list of a hundred and fifty documents is drawn from thirteen fields apiece, and it streams the head of the file and closes the handle at the closing fence rather than reading a hundred and fifty whole procedures.

The converter options are the ones the filter has always carried — 'html_input' => 'strip' and 'allow_unsafe_links' => false — so html written inside a document is dropped rather than escaped, and a javascript: link never reaches the page.

Nothing under Service/, Class/, Helper/ or Enum/ knows about a kernel. What MarkdownExtension adds on top is the one question that is about templates and not about markdown: a path written in a twig file is read from the project directory, and resolve() refuses anything that lands outside it. That check normalises . and .. on the text alone and never calls realpath() — in development the packages of the suite are symlinked into vendor/, and a real path would place them outside the project and refuse a file the template is entitled to read.

markdown_file() drops the front matter before rendering: it is what the file says about itself, addressed to the tooling, and printing it would put a rule and a list of colons at the top of the page.

Icon resolution

src/Service/IconService.php is the dispatcher. It takes the three managers by constructor injection and never touches the filesystem itself. Every entry point starts by splitting the name on AbstractIconManager::LIBRARY_SEPARATOR, which is ':':

[$prefix, $icon] = array_pad(explode(AbstractIconManager::LIBRARY_SEPARATOR, $name, 2), 2, '');

icon() then tries the managers in a fixed order — Phosphor, Material, Font Awesome — comparing $lib = $type ?? $prefix against each getLibraryKey() (ph, material, fa). Phosphor is checked first and unconditionally, since it needs no index. Material and Font Awesome are additionally gated on hasIcon($icon). When nothing matches, the service returns a bare <i class="icon"> containing the raw name, so an unresolved icon degrades to visible text rather than to an exception.

Two things an agent editing this file should know before changing behaviour. The docblock advertises auto-detection for unprefixed names, but the $lib === null branches guarding it are unreachable: array_pad fills with '', and an unprefixed "coffee" yields $prefix = 'coffee', $icon = ''. And iconSource() returns inline SVG for Material and Font Awesome only — Phosphor and the default case both return DomHelper::buildTag('span'), an empty tag, because Phosphor ships glyphs as a webfont with no SVG files to inline.

Icon managers and their index

src/Service/Icon/AbstractIconManager.php owns everything the three libraries share and leaves three abstract methods: getLibraryKey(), buildIconsList() and iconTag().

The index is built in the constructor, not lazily. On a cache miss it calls buildIconsList() and saves immediately; on a hit it restores the array:

$this->cacheItem = $this->cache->getItem($this->getCacheKey());

if (! $this->cacheItem->isHit()) {
    $this->icons = $this->buildIconsList();
    $this->saveRegistryCache();
}

Cache keys are 'symfony_design_system_icons_list_' . $this->getLibraryKey() — the symfony_design_system prefix is inherited from an earlier package and does not match this one's name. Rendered SVG is cached too, one entry per set of CSS classes keyed by md5(implode($classes)), written back into the same cache item by loadIconSvg(). That method parses the file with DOMDocument, appends icon plus the caller's classes to the root <svg> element's class attribute, and re-serialises. This is what ext-dom in composer.json is for.

iconList() is the exception to the caching: it calls buildIconsList() directly and rescans the filesystem each time.

The three concrete managers differ in where their icons come from:

  • src/Service/Icon/FaIconManager.php scans vendor/fortawesome/font-awesome/svgs/ under the project dir, two levels deep, keying icons as group/name (solid/coffee). Its iconTag() nests <i class="fa fa-solid fa-coffee"> inside the requested tag, turning the / into a second class.
  • src/Service/Icon/MaterialIconManager.php scans node_modules/material-design-icons/*/svg/production/, keeping only files matching ic_*_24px.svg and stripping both affixes. Its iconTag() emits the name as tag text with the material-icons class.
  • src/Service/Icon/PhosphorIconManager.php has no index at all: buildIconsList() returns [] and hasIcon() is overridden to $name !== ''. Names are style/icon and become the classes ph-bold ph-heart. Nothing is validated, and nothing is read from disk.

Both filesystem scans are guarded by is_dir() and silently produce an empty index when the library is not installed — a missing dependency shows up as icons rendering as text, not as an error.

Front-end assets

assets carries no PHP. assets/css/partials/_font-all.scss is the single entry point and only forwards the per-library partials:

@forward 'font-fa';
@forward 'font-phosphor';

Each partial imports from node_modules, and the libraries themselves are declared as peerDependencies in assets/package.json — the host application installs them. There is no Material partial: that library is served by the material-icons font class alone.

TemplateHelper

src/Helper/TemplateHelper.php is a static-only class about view paths and names, unrelated to icons and to the container. It holds TEMPLATE_FILE_EXTENSION = '.html.twig' and VIEW_PATH_PREFIX = '@', and offers removeExtension(), trimPathPrefix(), joinNormalizedParts() (snake-cases each part before joining), explodeControllerNamespaceSubParts() (drops the bundle-length prefix from a controller namespace, or two segments when no bundle is given) and stripTwigContextKeys(). Add a pure string function here; add anything needing the kernel, the cache or Twig under src/Service/, where the container will autowire it.

Integration in the Suite

This package is part of the Wexample Suite — a collection of high-quality, modular tools designed to work seamlessly together across multiple languages and environments.

Related Packages

The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.

Visit the Wexample Suite documentation for the complete package ecosystem.

Dependencies

  • php: >=8.5
  • ext-dom: *
  • league/commonmark: ^2.4
  • symfony/yaml: >=6.2
  • wexample/symfony-helpers: >=9.0.0

Versioning & Compatibility Policy

Wexample packages follow Semantic Versioning (SemVer):

  • MAJOR: Breaking changes
  • MINOR: New features, backward compatible
  • PATCH: Bug fixes, backward compatible

We maintain backward compatibility within major versions and provide clear migration guides for breaking changes.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Free to use in both personal and commercial projects.

About us

Wexample stands as a cornerstone of the digital ecosystem — a collective of seasoned engineers, researchers, and creators driven by a relentless pursuit of technological excellence. More than a media platform, it has grown into a vibrant community where innovation meets craftsmanship, and where every line of code reflects a commitment to clarity, durability, and shared intelligence.

This packages suite embodies this spirit. Trusted by professionals and enthusiasts alike, it delivers a consistent, high-quality foundation for modern development — open, elegant, and battle-tested. Its reputation is built on years of collaboration, refinement, and rigorous attention to detail, making it a natural choice for those who demand both robustness and beauty in their tools.

Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.

Migration Notes

When upgrading between major versions, refer to the migration guides in the documentation.

Breaking changes are clearly documented with upgrade paths and examples.