NAME
Mojolicious::Plugin::Branding - Global and per-request branding of templates and public files
VERSION
This document describes Mojolicious::Plugin::Branding version 0.004.
SYNOPSIS
# Mojolicious::Lite
# individual branding: the application decides the brand of each
# request and puts it in the stash
my %brand_for = (
'acme.example.com' => 'acme',
'umbrella.example.com' => 'umbrella',
);
hook before_dispatch => sub {
my $c = shift;
$c->brand($brand_for{$c->req->url->to_abs->host // ''});
};
plugin Branding => {
# global branding: these directories override the application's
# own templates/ and public/, first listed wins
global => [ '/etc/myapp/look-and-feel' ],
# individual branding: the directories of each brand
individual => {
brands => {
acme => 'brands/acme',
umbrella => [ 'brands/umbrella', '/srv/shared/umbrella' ],
},
},
};
DESCRIPTION
This plugin lets you change the templates and public files of a Mojolicious application without touching its source code. It supports two styles of branding, which can be used separately or together.
- Global branding
-
A project ships its own "brand-neutral" templates and public files, and whoever deploys it can customize the look and feel by pointing the plugin to one or more directories with the same layout:
/etc/myapp/look-and-feel/templates/layouts/default.html.ep /etc/myapp/look-and-feel/public/css/site.css /etc/myapp/look-and-feel/public/img/logo.pngFiles found there take precedence over the application's own; anything else falls back to the application. See "global".
- Individual branding
-
In a multi-tenant setup the brand is decided for each request, e.g. from the
Hostheader, and each brand keeps its own files in one or more directories:brands/acme/templates/index.html.ep brands/acme/public/css/site.css brands/umbrella/public/img/logo.pngAgain, only the files that a brand overrides need to be there, everything else falls back to the (possibly globally branded) application files. Deciding the brand of a request is up to the application, see "individual" and "How the brand is applied".
Templates and public files are looked up at the same URLs and with the same names as usual, so stylesheet '/css/site.css' or render(template => 'index') pick the branded version automatically. This includes layouts and partial templates.
CONFIGURATION
The plugin accepts a hash reference with the following keys, both optional. Any other key makes the plugin croak.
global
global => '/path/to/dir',
global => [ 'dir1', $mojo_file, ... ],
global => {
paths => '/etc/myapp/look-and-feel',
public => '/srv/myapp/static',
},
A specification as for a brand (see "brands"), except that "ignore_app_paths" is not allowed: e.g. one directory or an array of directories, each with templates and public sub-directories (if present). The resulting directories are put in front of the application's renderer and static paths; the order among them, and the handling of undef items and missing directories, are the same as for a brand. This allows using the same configuration across environments, e.g. global => [ $ENV{MYAPP_LOOK} ] (an empty variable makes the plugin croak, see "brands").
When the plugin is registered more than once with global, the directories of each registration are put in front of those of the earlier ones (last in, first out): a later global wins, while the earlier ones are still used for the files it does not have.
individual
A hash reference with the following keys, both optional (any other key makes the plugin croak):
- brands
-
brands => { acme => 'brands/acme', umbrella => [ 'brands/umbrella', '/srv/shared/umbrella' ], special => { paths => 'brands/special', templates => { paths => '/srv/special/views', ignore_app_paths => 1 }, public => '/srv/special/static', }, },A hash reference from brand names to their specification. The name is what the application puts in the stash (see "stash_key") and does not need to match any directory name. A specification is either:
one directory or an array of directories, short for
{ paths => ... };a hash reference with the following keys, all optional:
- paths
-
One directory or an array of directories, each holding the files of the brand in its
templatesandpublicsub-directories (both optional):<dir>/templates/... <dir>/public/... - templates
- public
-
Further directories of templates or public files, used directly (i.e. they are not expected to have
templatesorpublicsub-directories). Either one directory, an array of directories, or a hash reference with keys:- paths
-
one directory or an array of directories;
- ignore_app_paths
-
if true, the application's own directories (including the "global" ones) are not used for this brand, e.g. a template or public file that the brand does not have is not found, instead of falling back to the application's one. Files embedded in
__DATA__sections and the files bundled with Mojolicious are still found. If the brand ends up with no directories of that kind, e.g. because they are missing on this installation, a warning is logged at startup: the brand would have no templates or public files at all.
For each brand, templates and public files are searched in this order: the directories in "templates" or "public", then the matching sub-directories of the directories in "paths", then the application's directories (unless "ignore_app_paths"). In each list, the first directory wins.
Directories are given as strings or Mojo::File objects; relative paths are resolved from the application home.
undefitems and directories that do not exist are silently skipped. A brand whose specification isundefis not configured, and is removed if an earlier registration configured it: this allows e.g.acme => $ENV{ACME_LOOK}when the variable is not set (an empty one makes the plugin croak). A brand whose directories are all skipped is still a valid brand, it just has no files of its own.The plugin croaks on empty strings, on references other than Mojo::File objects where a directory is expected, and on unknown keys in a specification, so that a typo like
ignore_app_pathdoes not go unnoticed. - stash_key
-
stash_key => 'tenant',The stash key where the application puts the brand of the request,
branding.brandby default. See "How the brand is applied".
The plugin can be registered more than once. Brands from all registrations are collected; when a brand is configured again, the later specification replaces the earlier one entirely (unlike "global", see there).
Individual branding is set up by the first registration whose individual has a "stash_key" or names at least one brand in "brands", even with an undef specification: this applies roles to the application's renderer and static file server and adds the "brand" helper. Registrations without individual, or whose individual is empty or has only an empty "brands", leave them alone. "stash_key" is set by the registration that sets up individual branding (to branding.brand if not given); later registrations can omit it or repeat the same value, any other value makes the plugin croak.
How the brand is applied
The plugin does not decide the brand of a request: the application puts it in the stash key "stash_key" (branding.brand by default), e.g. with the "brand" helper from a before_dispatch hook. The brand can come from anywhere: the Host header (as in the "SYNOPSIS"), a header set by a reverse proxy, a cookie, a database...
Each brand gets its own Mojolicious::Renderer and Mojolicious::Static, created the first time they are needed (a brand with no directories of its own, and not ignoring the application's ones, just uses the application's). Their directories are those of the brand followed by the application's ones (see "brands"), the other settings are copied from the application's renderer and static file server at that moment: the handlers and the helpers are shared, and each brand gets its own template cache, of the same class and size as the application's. Roles already applied to the application's renderer or static file server apply to the brand ones too, but attributes added by those roles are not copied and start from their defaults. When a request has a brand, the application's renderer and static file server just hand the work over to the brand ones, which then follow the usual Mojolicious rules.
Public files are served by Mojolicious itself, at the usual point of the request: after all before_dispatch hooks, whatever the order in which they and the plugin were registered, and only if none of them has already rendered a response or called render_later. The static prefix, after_static hooks and reply->static all work as for the application's files. Hence, the brand must be in the stash by the end of the before_dispatch hooks for public files to be branded.
Templates are rendered according to the brand in the stash at the time of rendering. This covers layouts, partial templates (include) and the pages for exceptions and missing resources too, e.g. a brand can override just layouts/default.html.ep and keep the application's pages. The brand can also be set later than for public files, e.g. by a route with $c->brand('acme'); this changes the brand of the templates, but not of the public files, which are served before routing. Brand templates can use any handler (e.g. .ep or .epl), independently of the application's template with the same name.
The brand and variant are independent: the application can keep using variants (e.g. phone) and brands can provide their own variant templates (e.g. index.html+phone.ep). As usual in Mojolicious, a variant template found in any directory wins over a plain template, so a variant template of the application is preferred over a plain template of the brand: a brand overriding a page should also override its variants.
Only brands listed in "brands" are ever used: any other value in the stash, including one coming straight from the request, just means "no branding". There is no need to validate the brand against path traversal, although the application should still make sure that it is allowed for the request.
INTERFACE
brand
my $brand = $c->brand;
$c->brand('acme');
$c->brand('acme')->render(template => 'index');
Helper to get or set the brand of the request, i.e. the stash value at "stash_key". Setting undef, or a brand that is not in "brands", means no branding. When setting, it returns the controller, so that calls can be chained. The helper is only available once individual branding is set up (see "individual").
register
$plugin->register($app, \%config);
Registers the plugin in the application, see "CONFIGURATION". Called by "plugin" in Mojolicious, you should not need to call it directly.
BUGS AND LIMITATIONS
Minimum perl version 5.24, minimum Mojolicious version 9.34.
url_for_assetuses the application's static files and ignores the brand.Mojolicious computes ETags of static files from their modification time only. When the brand does not come from the host (e.g. from a cookie), a browser switching brand may get a
304 Not Modifiedfor a file of the previous brand. In that case, put the brand in the URL.Directories, both global and of brands, are checked at startup: adding a new directory, or a
templatesorpublicsub-directory to an existing one, requires a restart.The renderer and static file server of a brand copy the application's directories and settings (e.g. the static
prefix) when they are created, i.e. at the first request for that brand in each process. Later changes to the application's directories and to settings like the staticprefixare not seen by the brand, while handlers, helpers and classes are shared, so additions to them are. Attributes added by roles are not copied at all: a role that needs configuration on the application's renderer or static file server gets its defaults in the brand ones.As Mojolicious does for the application at startup, the renderer of a brand indexes its templates when it is created: new templates with a handler other than the default one, and new variant templates, need a restart. Changes to existing files and new public files are picked up as usual. Note that
morboonly watcheslibandtemplatesby default: use e.g.morbo -w brandsduring development.Each brand compiles and caches the templates it renders, including the application's ones, separately from the application and from other brands. This happens once per brand and process, at the first rendering of each template, and the cache of each brand is limited like the application's one.
Only rendering and serving files are branded: calling methods of the application's renderer or static file server directly (e.g.
$app->renderer->template_path(...)) ignores the brand, and so would a template handler that uses$app->rendererinstead of the renderer it is given.Setting up individual branding applies roles to the application's renderer and static file server, which changes their class. Applying further roles to them later with the
+Nameshorthand of "with_roles" in Mojo::Base then looks for the role under the new class name and fails: apply such roles before registering the plugin, or use the full role name.As in plain Mojolicious, a leading
/in a template name is ignored, but only for templates with the default handler: e.g.render(template => '/page')does not findpage.html.epl.
Report bugs through Codeberg (patches welcome) at https://codeberg.org/polettix/Mojolicious-Plugin-Branding.
USE OF AI
This module was developed with the help of an AI coding assistant (Claude, by Anthropic). It was used to review the code and find bugs, to write most of the test suite and to draft this documentation. All changes were reviewed by the author, who designed the module and wrote or approved every fix.
AUTHOR
Flavio Poletti <flavio@polettix.it>
COPYRIGHT AND LICENSE
Copyright 2026 by Flavio Poletti <flavio@polettix.it>
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.