SIGNALS Documentation
API Reference

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:

  • currencyCode for currency-sensitive formatting
  • unknownFieldBehaviour for 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 (AZ or az). 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:

  • date
  • time
  • currency
  • number
  • upper
  • lower
  • title
  • default
  • truncate
  • plural

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 registered
  • get($name) returns the callable or null
  • all() 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:

  • currency expects integer minor units and uses MergeFieldRenderContext::$currencyCode when no explicit code argument is supplied
  • date and time use the timezone helper plus the configured company formats unless an explicit format argument is passed
  • default is registered like any other filter; when the renderer reaches that filter with null or 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.