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.png
Files 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 Host header, 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.png
Again, 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 templates and public sub-directories (both
optional):
/templates/...
/public/...
templates
public
Further directories of templates or public files, used directly
(i.e. they are not expected to have templates or public
sub-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. undef items and
directories that do not exist are silently skipped. A brand whose
specification is undef is 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_path does not go
unnoticed.
stash_key
stash_key => 'tenant',
The stash key where the application puts the brand of the request,
branding.brand by 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_asset uses 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 Modified for 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 templates or public sub-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 static prefix are 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 morbo only watches lib and templates by
default: use e.g. morbo -w brands during 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->renderer instead 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 +Name shorthand 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 find page.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
COPYRIGHT AND LICENSE
Copyright 2026 by Flavio Poletti
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.