Merge Field Filter Registry
Contract for registering merge-field filter callbacks and applying them during communication-template rendering.
Overview
App\Services\Notifications\MergeFieldFilterRegistry is the filter catalogue behind the notification template renderer. It stores named callbacks that transform merge-field values during MergeFieldRenderer substitution.
Core binds the registry as a singleton in AppServiceProvider, then CoreMergeFieldFilters::register() loads the shipped filters during boot. Plugins currently register additional filters through PluginRegistrar::registerFilter().
Public surface
| Method | Purpose |
|---|---|
register() |
Store one callback under a filter name |
has() |
Check whether a filter name exists |
get() |
Return the callback for a filter name or null |
all() |
Return the full name-to-callback map |
apply() |
Invoke a filter with value, optional argument, and render context |
Accepted callback contract
Every registered filter is a callable with this signature:
callable(mixed $value, ?string $arg, MergeFieldRenderContext $context): mixed
The callback receives:
- the current field value after any earlier filters in the chain
- the optional filter argument parsed from
filter:arg - the current
MergeFieldRenderContext
MergeFieldRenderContext currently carries:
currencyCodefor currency-sensitive formattingunknownFieldBehaviourfor blank-versus-placeholder rendering
Registry names are unrestricted string keys: register() performs no format validation, and direct get() or apply() calls can use any exact registered string. The template grammar is narrower. In a {{ ... }} expression, template-expression filter names must contain alphabetic characters only (A–Z or a–z). A registered name containing digits, punctuation, underscores, or hyphens remains directly callable but cannot be referenced by the current template-expression parser. Names are case-sensitive at both boundaries.
Current core filters
CoreMergeFieldFilters::register() currently ships these filter names:
datetimecurrencynumberupperlowertitledefaulttruncateplural
These are the current built-ins, not an open-ended generated catalogue.
Built-in argument contracts
| Filter | Value and argument behavior |
|---|---|
date |
Accepts a string or DateTimeInterface; the optional argument is a PHP date format, otherwise company.date_format_php or d/m/Y is used |
time |
Accepts a string or DateTimeInterface; the optional argument is a PHP time format, otherwise company.time_format_php or H:i is used |
currency |
Requires integer minor units, accepting integers or whole-number strings; the optional argument is a currency code, otherwise the render context's code is used |
number |
number accepts numeric input and treats its optional argument as an integer decimal count, defaulting to 2; non-numeric values pass through unchanged |
upper |
Casts non-null input to string and uppercases it; the argument is ignored |
lower |
Casts non-null input to string and lowercases it; the argument is ignored |
title |
Casts non-null input to string, lowercases it, then title-cases words; the argument is ignored |
default |
Replaces null or the empty string with its argument, or with an empty string when no argument is present |
truncate |
truncate treats its optional argument as an integer length, defaulting to 50; longer strings return a prefix of max(0, length - 3) characters followed by ..., so requested lengths below 3 still return ... and exceed that requested length |
plural |
plural requires a singular form in its argument to transform the value; `singular |
Lookup, replacement, and invocation rules
Lookup
has($name)reports whether a filter is registeredget($name)returns the callable ornullall()returns the raw associative map exactly as the registry stores it
Replacement behavior
Filter names are associative keys. Registering the same name twice replaces the earlier callback. The registry does not reject collisions or accumulate multiple callbacks under one name.
Invocation
apply($name, $value, $arg, $context) looks up the callback and invokes it directly when found.
MergeFieldRenderer applies chained filters from left to right, so each callback receives the output of the previous filter in the expression. Its special handling for an empty value occurs at the point where default is encountered in that sequence. It is not a global pre-dispatch short circuit: earlier filters run first, and later filters receive the default literal as their input.
Missing-filter behavior
If apply() cannot find the named filter, it returns the original $value unchanged. Unknown filter names are therefore a no-op at the registry layer rather than an exception.
Rendering context and current behavior
The registry is consumed by MergeFieldRenderer, which parses expressions shaped like:
{{ contact.name | upper | truncate:12 }}
The renderer resolves the field value first, then calls apply() for each parsed filter. Current durable behavior includes:
currencyexpects integer minor units and usesMergeFieldRenderContext::$currencyCodewhen no explicit code argument is supplieddateandtimeuse the timezone helper plus the configured company formats unless an explicit format argument is passeddefaultis registered like any other filter; when the renderer reaches that filter withnullor an empty string, it inserts the argument directly and continues through the remaining left-to-right pipeline
That special handling is part of the renderer contract, not additional registry behavior.
Invariants and failure modes
Duplicate names
Later registrations replace earlier callbacks for the same filter name.
Missing filters
Missing filters are non-fatal. apply() returns the input value unchanged.
Filter-specific failures
The registry does not catch exceptions thrown by a concrete filter. For example, the core currency filter rejects floats and decimal strings because it requires integer minor units.
Worked example
This is the current core registration and consumption shape exercised in renderer tests:
$registry = new MergeFieldFilterRegistry;
CoreMergeFieldFilters::register($registry);
$rendered = $registry->apply(
'upper',
'abc',
null,
MergeFieldRenderContext::defaults(),
);
The core registration stores the upper callback, and apply() returns ABC. Re-registering upper later would replace that callback rather than appending another stage.