restruct / silverstripe-favicons
One PNG to every favicon, touch icon and web manifest variant browsers ask for, served at the paths they probe, on the frontend, in the CMS and on the login pages
Package info
github.com/restruct/silverstripe-favicons
Type:silverstripe-vendormodule
pkg:composer/restruct/silverstripe-favicons
Fund package maintenance!
Requires
- php: ^8.1
- silverstripe/admin: ^2 || ^3
- silverstripe/assets: ^2 || ^3
- silverstripe/framework: ^5.4 || ^6
- silverstripe/siteconfig: ^5.4 || ^6
Requires (Dev)
- silverstripe/recipe-testing: ^3 || ^4
Suggests
- restruct/silverstripe-admintweaks: Its SiteConfig.FavIcon and ThemeBrowserColor are used as fallbacks when set
- silverstripe/asset-admin: Provides the upload field for allow_upload: true
- silverstripe/cms: Adds the icon tags to frontend pages (ContentController)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 11:05:05 UTC
README
Maintained by Restruct. If this module saves you time, you can support ongoing maintenance.
One PNG in, every favicon and touch icon browsers and devices ask for out: served at the paths they probe, and linked from the frontend, the CMS (so the backend tab shows the site's icon, not a generic one) and the login pages.
Browsers and home-screen shortcuts probe fixed root paths whether or not a page links them:
/favicon.ico, /apple-touch-icon.png, /apple-touch-icon-precomposed.png,
/apple-touch-icon-152x152.png, /manifest.json and more. Without a file there, each probe is a
404 that runs the full Silverstripe stack. This module answers all of them from one PNG, and
outside dev mode also writes them into the webroot, so the web server serves them without PHP.
Requirements
- Silverstripe 5.4 or newer, or 6 (
silverstripe/framework ^5.4 || ^6, admin, assets, siteconfig) - PHP 8.1 or newer
- A working Silverstripe image backend. The icons are drawn by the framework's own
Image_Backend(Intervention Image 2 on Silverstripe 5, 3 on Silverstripe 6, with the GD driver by default), so whatever already resizes your site's images resizes the icons too. The module makes no GD or Imagick calls of its own.
composer.json is the source of truth for supported versions.
| Branch | Module version | Silverstripe | PHP |
|---|---|---|---|
main |
1.x (unreleased) | 5.4+, 6 | 8.1+ |
Installation
composer require restruct/silverstripe-favicons
Point it at your icon, then run dev/build (SS5) or sake db:build (SS6):
# app/_config/favicons.yml Restruct\Favicons\FaviconService: source: 'themes/mytheme/images/icon.png'
dev/build generates the icons and prints one line saying what is being served, eg
Favicons: Serving all favicon variants from file:themes/mytheme/images/icon.png. The line is red
when a source is set but cannot be used, with the reason.
Existing icon files in the webroot
The web server serves an existing file before Silverstripe runs, so a public/favicon.ico,
public/apple-touch-icon.png or public/site.webmanifest the project ships (some skeletons have a
public/favicon.ico) would keep serving the old icon. Where the module writes its own files into
the webroot (see Files in the webroot), dev/build renames such a file
in place, eg to favicon.bu-20261012.ico, writes its own, and says so in its output: remove the
original from the project (its repository), or the next deploy brings it back. A file that is
byte-identical to the module's is simply kept. Each path is backed up only once: from then on the
file there is the module's, and it is overwritten without another backup.
Where the module does not write (dev mode, write_public_files: false, a webroot it cannot write,
the other servers of a multi-server setup) those files still win: delete them yourself.
nginx: some stock vhosts (Laravel Forge among them) contain an exact-match block such as
location = /favicon.ico { access_log off; log_not_found off; } without a try_files. With the
module's file in the webroot that block serves it as it is. Only where the module does not write
the file (see above) does nginx answer /favicon.ico itself with a 404, and with a server-level
error_page 404 /index.php the icon body then arrives with status 404. For those setups, pass the
request on to Silverstripe in that block:
location = /favicon.ico { access_log off; log_not_found off; try_files $uri /index.php?$query_string; }
Do the same for any similar block (e.g. location = /apple-touch-icon.png), or remove it.
Remove icon tags from your theme
The module adds its <link> tags to every page without template changes. If your theme's
Page.ss already has its own <link rel="icon">, apple-touch-icon or manifest tags, remove
them, or browsers get two competing sets. Alternatively keep the theme's tags and set
frontend_tags: false, in which case the module still answers the root probes and tags the CMS and
login pages.
To place the module's tags yourself (eg with frontend_tags: false, at a set spot in your own
<head>), use the $FaviconTags template variable:
<head>
<%-- ... --%>
$FaviconTags
</head>
It renders exactly the tags listed under What gets served, or nothing while no
icon set exists. It ignores the *_tags switches (a template that asks for the tags gets them), so
switch the automatic insertion off for the pages where you use it, or they carry the tags twice.
Like the automatic insertion it never throws: an error is logged and renders nothing.
The source icon
One PNG of at least 512x512 px: its shortest side at least 512 px, its longest side at
most max_source_size (2048 px by default). Square is best. A transparent background is fine and
usually best too: the variants that must be opaque get background_color behind the icon.
A non-square PNG is accepted and centre-cropped to a square of its shortest side, as online
favicon generators do: a 1024x600 source becomes its middle 600x600. It is cropped rather than
padded, because padding would invent a margin colour and put the icon off-centre in some variants.
If the part you want is not in the middle, crop the image yourself before uploading it. The crop is
done by the project's image backend (the same croppedResize() that Fill() uses), in the
AssetStore like everything else.
Anything else is refused with a reason, in the upload field, on dev/build and in the Settings
hint: a JPEG, WebP, GIF or SVG (also when renamed to .png; the file's header is checked, not its
name), a PNG whose shortest side is under 512 px (the 512 px variants would be upscaled and
blurry), and a PNG whose longest side is over max_source_size.
Where the icon comes from
The first usable one of these wins:
- The CMS upload, when
allow_upload: true(see From the CMS). - admintweaks' Settings icon:
SiteConfig.FavIconfrom restruct/silverstripe-admintweaks, when that module is installed, its field is filled in, anduse_admintweaksis on (the default). - The configured
source.
A source that is set but unusable (eg a 64 px logo in the admintweaks field) is skipped, and the
next one is used; dev/build and the Settings hint say which one was skipped and why. The order
puts the most deliberate choice first: this module's own field exists only because a project
switched it on, admintweaks' field is a general "logos" field, and YAML is the developer's default.
From configuration
source is a resource string, resolved through Silverstripe's ModuleResourceLoader, the same
resolver behind $resourceURL() in templates:
| Form | Example |
|---|---|
Module resource (vendor/package:path) |
mycompany/site-theme:client/images/icon.png |
| Path relative to the project root | app/images/icon.png, themes/mytheme/images/icon.png, public/icon.png |
| Absolute filesystem path | /srv/shared/icon.png |
It names one file. It is not looked up through the theme stack, so when the site switches to
another theme, update source to that theme's icon. A theme without its own composer.json is not
a module, so refer to it by path.
The file is read where it is: it does not become a File in the CMS.
From the CMS (optional)
Restruct\Favicons\FaviconService: allow_upload: true
adds a Site icon (favicon) upload field on Settings, under the tagline. A published upload overrules everything else; removing the upload falls back to the next source. The field's description says what is currently being served.
- The field accepts PNG only, and checks its size at upload: a refused file shows the reason in the field and is not stored. The field's own checks (allowed extensions, maximum file size, and silverstripe/mimevalidator's MIME check when installed) still run first.
- Choosing an existing file from the files area is switched off: that path skips upload validation. Saving Settings also re-checks a newly chosen icon, so one that reaches the relation some other way is refused on save.
- Only the published file is used: icons are fetched by anonymous visitors. Saving Settings publishes the upload (SiteConfig owns it). Once published it is served to everyone at the icon paths, whatever its CanView setting in the files area: do not upload anything you would not put on the public site.
- With
allow_upload: false(the default) a stored upload is ignored and the field is hidden. The database column exists either way, so switching the option never changes the schema.
With silverstripe-admintweaks
admintweaks' field is a general file picker: it also accepts a file chosen from the files
area, including one whose CanView is restricted, and does not check the image. Whatever it holds,
once it is a usable PNG, is published at the icon paths (as a re-encoded copy, see
Storage), whatever its permissions. Set use_admintweaks: false if editors use that
field for anything that must not be public.
Besides the icon (above), admintweaks' SiteConfig.ThemeBrowserColor is used as the theme
colour (<meta name="theme-color"> and the manifest's theme_color) when it holds a hex colour and
theme_color is not configured. Set use_admintweaks: false to ignore both fields. This module's
own upload is a separate FaviconSource relation, so the two fields never collide.
What gets served
All paths are relative to the site's base URL. Responses carry an ETag and answer If-None-Match
with a 304.
| Path | Content | Size | Background | Notes |
|---|---|---|---|---|
/favicon.ico |
ICO with PNG entries | 16, 32, 48 | transparent | ico_sizes |
/apple-touch-icon.png |
PNG | 180 | background_color |
iOS paints transparency black |
/apple-touch-icon-precomposed.png |
same as above | 180 | legacy iOS probe | |
/apple-touch-icon-120x120.png, -152x152, -167x167, -180x180 |
PNG | as named | background_color |
touch_sizes |
/apple-touch-icon-NxN[-precomposed].png, any other N |
nearest generated size | eg 240x240 gets the 180; 0x0 and non-square names are 404 |
||
/icon-192.png, /icon-512.png |
PNG | 192, 512 | transparent | referenced by the manifest |
/icon-maskable-512.png |
PNG | 512 | background_color |
maskable_padding safe zone |
/site.webmanifest, /manifest.json |
web app manifest (JSON) | manifest: true (default) |
||
anything in extra_paths |
the variant it maps to | eg sites/favicon.ico |
Paths are matched case-insensitively (/FAVICON.ICO works).
Every path above is answered by default. To hand one back to a route of your own (your own
/manifest.json, say), list it in disabled_paths:
Restruct\Favicons\FaviconService: disabled_paths: - 'manifest.json'
A disabled path is passed on to the rest of the site, and nothing the module emits points at it:
disabling site.webmanifest drops the <link rel="manifest">, favicon.ico or
apple-touch-icon.png drops its <link>, and icon-192.png, icon-512.png or
icon-maskable-512.png drops its entry from the manifest's icon list. Disabling only
manifest.json drops just that alias; the manifest stays linked at /site.webmanifest. Entries
are exact paths, not patterns: disabling apple-touch-icon.png leaves
apple-touch-icon-precomposed.png answered. To switch the whole manifest off, manifest: false is
still the simpler option. An extra_paths alias is a path like any other: listing the alias
disables it, while disabling the path it points at does not, because the alias was configured on
purpose. If the image backend cannot decode
the source (a damaged PNG with a valid header), that is remembered for failure_ttl seconds: the
image paths are 404, their tags are left out, the manifest lists no icons, one warning is logged
and dev/build prints a red line.
Tags added to the <head> of frontend pages, the CMS and the Security (login) pages:
<link rel="icon" href="/favicon.ico?v=..." sizes="32x32"> <link rel="apple-touch-icon" href="/apple-touch-icon.png?v=..."> <link rel="manifest" href="/site.webmanifest?v=..."> <!-- manifest: true --> <meta name="theme-color" content="..."> <!-- theme colour set -->
Storage
The generated files live in the framework's AssetStore, so they work with whatever adapter the
project uses (local disk, S3 and so on), under assets/_favicons/<set>/:
icon.png: the source as the image backend re-encodes it after shrinking it to the largest variant (512 px with the default sizes). Not the uploaded bytes: text, EXIF and other metadata chunks in the original are not published;- its PNG variants, stored as asset variants of that file (
icon__Favicon180Opaque.pngand so on), each drawn on and written by the framework's image backend; an existing one is reused, not redrawn; favicon.ico, packed from the 16/32/48 variants.
None of these has a File record, so nothing appears in the CMS files area. <set> is a hash of
the source content and the generation config (sizes, colours, margins), so a new icon or new config
is a new folder.
When a set is generated: on dev/build, when the icon changes in Settings (on save and
publish), and otherwise by the first icon request (/favicon.ico and the like, which browsers send
on their own). Only a set that does not exist yet is drawn: dev/build with an unchanged icon and
config finds the set in the registry and resamples nothing, and it leaves the
files in the webroot alone too: a file there is only written when it is
missing, comes from an older set, or was replaced since, so an unchanged build reads, hashes and
writes none of them. A new icon (or config) is a new set, which rewrites each file once. Never during a page render: until a set is complete, pages carry no icon tags,
and an error in the tag code is logged and leaves the tags out rather than breaking the page. One
build per set runs at a time (a "busy" marker, busy_ttl): concurrent first requests get a 404
instead of each decoding the source. A source the backend cannot decode, or whose decode kills the
worker (memory or time limit), is remembered for failure_ttl and not retried in that time.
When a new set is complete, the previous one is deleted. If the store refuses that delete (file
permissions, a remote error, dev/build run as another user than the web server), the old files
stay behind with one logged warning; nothing else is affected.
Which sets exist is remembered in the module's cache
(Psr\SimpleCache\CacheInterface.RestructFavicons): a plain filesystem pool under TEMP_PATH,
deliberately not created through Silverstripe's CacheFactory, so dev/build on the command line,
the CMS and the site all read the same entries (the factory picks a different adapter for CLI and
web on Silverstripe 5, and segments by stage with silverstripe/versioned). If that cache is
cleared, the set it named is no longer known: it is left behind when the next one is generated (a
few small files), and the tags disappear until the next icon request or dev/build re-registers
it. Delete assets/_favicons/ (or that prefix in your bucket) to remove leftovers.
Several web servers sharing one remote store: override that service with a shared pool (Redis, Memcached), so every server reads the same registry. With the default per-server pool it still works, at three costs:
- each server decodes the source once to build its own registry entry (the stored files themselves are reused: variants that exist are not redrawn);
- a server shows no icon tags until its first icon request or
dev/build; - servers with different libgd versions may encode
icon.pngdifferently. The first copy stored is kept (CONFLICT_USE_EXISTING) and the other servers adopt it, so they do not overwrite each other's copy.
The build lock (one build per set at a time) is a file in each server's TEMP_PATH, so it only
coordinates the requests of one server.
Memory
Generation decodes the source once, through the image backend. Whether that bitmap counts against
PHP's memory_limit depends on how GD was built:
- PHP's bundled libgd (
gd_info()reports "bundled", as in the official Docker images): libgd allocates through PHP's own allocator (php-srcext/gd/libgd/gdhelpers.hmapsgdMalloctoemalloc), so the decode counts againstmemory_limitand a too-large source ends in a memory-limit fatal (remembered forfailure_ttl). - An external libgd (most distribution and Homebrew packages): its memory is invisible to
memory_limit, so only the process size grows.
Peak process size (RSS) of one generation, measured on PHP 8.3 with Homebrew's external libgd 2.3.3 and the default GD driver:
| Source | Silverstripe 5 | Silverstripe 6 |
|---|---|---|
| 512 px | 54 MB | 50 MB |
| 1024 px | 66 MB | 58 MB |
| 2048 px | 99 MB | 79 MB |
| 4096 px | 237 MB | 176 MB |
That is why max_source_size defaults to 2048: a larger source adds nothing to a 512 px icon.
A non-square source costs one extra full-size bitmap at its peak: the backend's croppedResize()
works on a copy of the decoded image, which is released once the cropped square exists. The table
above was measured on square sources only. max_source_size bounds the longest side, so a
non-square source never decodes to more than a square one at the same limit.
Caching and serving
- The root paths are answered by streaming the file from the AssetStore, not by redirecting to
/assets/...: a redirect costs every probe a round trip, and some clients do not follow redirects for icons. With a remote adapter each uncached probe therefore reads from the store; the cache headers below keep that rare. ?v=is a hash of the set and the manifest contents. A request carrying the currentvis answered withCache-Control: public, max-age=31536000, immutable. A bare probe (/favicon.ico, no query) getsmax_age(one day) plus the ETag, so a changed icon reaches returning visitors within a day and revalidation is a 304 without a body.- The icon routes run as a request middleware before routing, so no page logic runs and no new session is started. Core's trusted-proxy, allowed-hosts, session, cache-control, canonical-URL, authentication, basic-auth and password-expiration middleware run before it (so basic auth still protects a staging site's icons); middleware registered after it, such as URL specials, dev-URL confirmation, session-manager's LoginSession and versioned's stage handling, is skipped for icon requests (order listed from a booted SS5 and SS6 install).
- A 304 carries the variant's
Content-Typetoo (not required, but some proxies key on it).
Files in the webroot
Outside dev mode, dev/build and a Settings save that changes the icon (or the site title, which
the manifest carries) also write every root path above into the webroot (public/): favicon.ico,
apple-touch-icon.png, apple-touch-icon-precomposed.png, the apple-touch-icon-NxN.png sizes in
touch_sizes, icon-192.png, icon-512.png, icon-maskable-512.png, site.webmanifest,
manifest.json, and extra_paths aliases that are a plain file name at the root. The web server
then answers those probes itself; the PHP route stays as the fallback, and still answers every
other apple-touch-icon-NxN[-precomposed].png name, nested aliases (eg sites/favicon.ico: a
folder in the webroot could shadow a page URL), and anything the module could not write. Normal
page requests never write.
dev/build prints what it did, eg Favicons: Icon files in the webroot (/srv/site/public): wrote favicon.ico, ... or 12 up to date, plus any rename (see
Existing icon files) and, in red, any file it had to skip.
- Which files are the module's is kept in the module cache (per webroot: the set each file came
from, and its size, mtime and inode), the same cache as the registry of generated sets. That is
how an unchanged
dev/buildtouches nothing, and how a new icon replaces the old files without backing them up. If the cache is cleared, an existing file that differs from the new one is treated as foreign once (one backup), never lost. - Stale files are removed: when the source goes away or cannot be decoded, a path is added to
disabled_paths, the manifest is switched off, or the feature itself is switched off (or the site is put in dev mode), the files the module wrote are deleted, so an old icon is never served ahead of the route. A file that was changed since the module wrote it is left alone. - Not writable: when the webroot is not writable for the user running
dev/build(or the web server, for a Settings save), nothing is written anddev/buildsays so in one line; the route answers as before. - Dev mode: nothing is written, because a local webroot is usually a git working tree, where
the files and a renamed
favicon.icowould show up as changes. Setwrite_public_files_in_dev: trueto write there too.write_public_files: falseswitches the feature off everywhere (files written earlier are then removed on the nextdev/build). - Deploys: the files are not in your repository; ignore them there if your deploy pulls into a
working tree (eg
public/favicon.ico,public/apple-touch-icon*.png,public/icon-*.png,public/site.webmanifest,public/manifest.json,public/*.bu-*). With zero-downtime deploys each release has its ownpublic/anddev/buildruns in it, so each release gets its files before it goes live. - Several web servers: only the server that runs
dev/build(or handles the Settings save) gets the files; the others answer through the route. A later icon change saved through another server leaves the first server's files stale until its nextdev/build. Unless every server runsdev/buildon each change, orpublic/is shared, setwrite_public_files: falsethere. - Content type: the web server picks it from the extension.
.webmanifestshould beapplication/manifest+json(nginx's stockmime.typeshas it; add it to older Apache setups), and the files get the web server's own cache headers instead of the module's. - Subdirectory installs: the manifest's
start_urland icon URLs come from the base URL; setSS_BASE_URLsodev/buildon the command line writes the right ones.
Configuration reference
All on Restruct\Favicons\FaviconService.
| Option | Default | Meaning |
|---|---|---|
source |
null |
The icon, see From configuration |
allow_upload |
false |
Show the upload field on Settings; a published upload overrules every other source |
upload_folder |
'favicon' |
Asset folder for uploads |
use_admintweaks |
true |
Use admintweaks' FavIcon and ThemeBrowserColor as fallbacks when set |
max_source_size |
2048 |
Largest accepted source edge in px, see Memory; 0 for no limit |
busy_ttl |
120 |
Seconds a set counts as "being generated" if the worker building it dies |
background_color |
'#ffffff' |
Fill for the opaque variants (touch icons, maskable); also the manifest default |
touch_icon_padding |
0 |
Margin around the icon on the touch icons, % of the edge per side |
maskable_padding |
10 |
Margin on the maskable icon, % per side (10 keeps it inside Android's 80% safe circle) |
ico_sizes |
[16, 32, 48] |
Sizes inside favicon.ico (1-256) |
touch_sizes |
[120, 152, 167, 180] |
apple-touch-icon-NxN.png sizes; 180 is always included |
manifest |
true |
Serve /site.webmanifest and /manifest.json and link it |
manifest_fields |
see below | Manifest members |
theme_color |
null |
<meta name="theme-color"> and manifest theme_color; null falls back to admintweaks' ThemeBrowserColor, else nothing |
extra_paths |
[] |
Extra request paths, path: variant |
disabled_paths |
[] |
Request paths not to answer (and not to link), so a project route can have them |
frontend_tags |
true |
Add the tags on frontend pages (ContentController) |
admin_tags |
true |
Add the tags in the CMS (LeftAndMain) |
security_tags |
true |
Add the tags on Security pages (login, lost password) |
max_age |
86400 |
Cache lifetime for a bare probe, seconds |
versioned_max_age |
31536000 |
Cache lifetime for a ?v= URL, seconds (sent with immutable) |
failure_ttl |
600 |
Seconds an undecodable source (or one that killed the worker) is remembered (variants withdrawn, not retried) |
asset_folder |
'_favicons' |
AssetStore folder for the generated sets |
write_public_files |
true |
Also write the root-path files into the webroot, see Files in the webroot |
write_public_files_in_dev |
false |
Write them in dev mode too |
manifest_fields defaults: name and short_name empty (both fall back to the SiteConfig
title), start_url empty (the site's base URL, so a subdirectory install works), display: 'browser' (adding the site to a home screen keeps it a normal website; set standalone
deliberately if you want app-like behaviour), background_color and theme_color empty (falling
back to the options above). Empty members are left out of the JSON. Keys other than these pass
through as they are, so members such as id, scope or orientation can be set the same way.
For a member that has to be computed (per environment, from SiteConfig, extra icons), add an
extension to FaviconService with an updateManifestData hook. It receives the members (by
reference, icons included) and the IconSource in use (or null), before empty members are dropped,
so setting a member to null removes it. The result is part of the ?v= version, so a change is
picked up by browsers:
use Restruct\Favicons\IconSource; use SilverStripe\Core\Extension; class ManifestExtension extends Extension { protected function updateManifestData(array &$fields, ?IconSource $source): void { $fields['id'] = '/'; $fields['scope'] = '/'; } }
Restruct\Favicons\FaviconService: extensions: - App\Extensions\ManifestExtension
Example with an alias for a probe seen in a 404 log:
Restruct\Favicons\FaviconService: source: 'app/images/icon.png' allow_upload: true background_color: '#0b3d91' touch_icon_padding: 8 theme_color: '#0b3d91' manifest_fields: short_name: 'Example' extra_paths: 'sites/favicon.ico': 'favicon.ico'
Running the tests
From a host project that requires this module through a symlinked path repository (/tests is
export-ignore, so a dist install has no tests):
# Silverstripe 5 (PHPUnit 9): the path first, then flush=1 vendor/bin/phpunit vendor/restruct/silverstripe-favicons/tests flush=1 # Silverstripe 6 (PHPUnit 11): a bare flush=1 is read as a test path, use the environment variable SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit vendor/restruct/silverstripe-favicons/tests
Browser tests (Playwright, tests/browser/) check that the CMS and login pages link the
configured icon, that a Settings upload replaces it end to end, and that a too-small PNG is refused
at upload. They run through the maintainers' shared runner, which
builds one scratch host per Silverstripe major, and in CI through .github/workflows/browser-tests.yml.
License
MIT, see LICENSE.