Skip to main content
Version: v0.2 (next)

The Filter Pipeline

As of v0.2, FLARE's filtering is built from four decoupled concepts:

  • Filter Controls (HeimrichHannot\FlareBundle\Filter\Control\) own how the user supplies a value: the frontend form fields, their own config and backend palette slice, and the translation of submitted data into a value object. See Filter Controls.
  • Value Objects (HeimrichHannot\FlareBundle\Filter\Value\) are immutable, typed records of what the user said — BoolValue, ChoiceValue, DateRangeValue, KeywordsValue — the channel between a control and an element. A value provider turns the stored value column into one.
  • Filter Elements (HeimrichHannot\FlareBundle\Filter\Element\) own what rows match: the canonical config schema, the backend DCA representation, and the translation of config + value object into predicate calls.
  • Predicates (HeimrichHannot\FlareBundle\Filter\Predicate\, formerly "filter types") are small, reusable, stateless query-fragment builders — the only components that write SQL conditions. See Predicates.

A filter is applied in two stages: a value-free, role-typed scope (list × filter × engine context) is what hooks receive before a value exists — an ElementScope for the element's buildContext() and its ports, a ControlScope for the control; $scope->get('key') reads the calling component's own config. The FilterContext is the element's product for one invocation, built once a value is known.

The pipeline, end to end:

tl_flare_filter rows ──(collector + transformers + value provider)──▶ Filter[] on the ListSpec
│
form building: control->buildForm(builder, scope) per filter ──▶ FormHarness (root form + mounts)
│
submit: control->decode(data, scope) ──▶ value object per filter alias
│
query building (ListQueryDirector): element->buildContext(builder, scope, value) ──▶ Proposition[]
│
predicate->buildConditions() ──▶ FilterConditionsBuilder[] ──▶ SQL conditions

1. The Filter Value Object​

HeimrichHannot\FlareBundle\Filter\Filter is the immutable runtime representation of a single filter within a list. It binds a filter element with its canonical configuration, an optional control with its own, and an optional value — no DCA or storage specifics.

Constructor properties:

PropertyDescription
elementAn ElementBinding: component (the FilterElementInterface — a registered element service or an inline instance), type (the registered element type alias, e.g. flare_bool, used for the flare.filter_element.{type}.* named events and targeting lookups) and config (the canonical element config following the element's schema, elm_* columns)
aliasThe filter's key within the list and its form name / query-parameter name. An alias that is not a valid Symfony form name never mounts form children
controlA ControlBinding — component (the FilterControlInterface), type (the registered control type, or the class name of an inline control, used for the flare.filter_control.{type}.* named events) and config (the canonical control config, ctl_* columns) — or null for a filter without user input (intrinsic)
valueThe filter's ValueInterface — the fixed value without a control, the initial value with one
targetAliasTable alias the filter's conditions apply to
targetingForcedWhether the target alias applies even if the element is not marked as targeted
sourceProvenance for error messages, e.g. tl_flare_filter.42

Both configs are a ComponentConfig (HeimrichHannot\FlareBundle\Config\ComponentConfig): immutable, holding scalars, arrays and enums only. get('key') returns a value — null for an option defined without default and not set — and throws UndefinedConfigKeyError (an \Error) for a key the component's schema does not define; has('key') and all() complete it. Inside a hook, $scope->get('key') is the short way to the own config.

Because Filter is immutable, all modification happens through withers that return a new instance: withValue(), withControl(?ControlBinding), withTargetAlias(?string, bool $forced = true).

Filters are created without a database row through the FilterFactory service — which resolves registered element aliases and control names to their services — or by constructing a Filter directly around an inline element instance; see Programmatic Filters. Filter::fingerprint() returns a representation (element class, configs, control class and the value's fingerprint()) used by ListSpec::hash() for in-request caching.

Fixed vs. initial value. The control column of a filter record decides the role of its value:

controlvalueinitial display of the listwhole list (readers, validation)
blank (intrinsic)fixed valuerestrictedrestricted
a controlinitial valuepre-filtered, user can change itnot applied

2. Collection​

The ListModelFilterCollector (Filter\Collector\) reads the published tl_flare_filter rows of a tl_flare_list record; the FilterFactory translates each row into a Filter: element config through the element's transformers (the configureTransformers() map, usually the transformFilterModel() hook — see Config Schema), the control and its config through the control's transformers, and the value through the value provider for the element's value class. For every collected filter, a FilterCollectedEvent is dispatched before it is added to the list — listeners may replace the (mutable) $event->filter, e.g. to change its config or target alias.

3. Form Building​

For each filter with a control whose alias is a valid Symfony form name, the FormHarnessFactory creates a ControlScope and hands a collect-only per-filter builder (FilterFormBuilderInterface) to the control's buildForm(). Single-field controls declare their one field via single() — it is mounted flat on the root form under the filter's alias (form[alias]=x). Multi-field controls add() children under local names, which mount as a compound sub-form (form[alias][from]=x); compound() sets its form options, e.g. a label. Each mount is hydrated with control->hydrate($filter->value, $scope).

The per-filter builder carries the ControlScope in its attribute bag under FilterFormBuilderInterface::ATTR_SCOPE. After the control built its fields, a FilterControlBuildFormEvent is dispatched — listeners can add, remove, or replace children, adjust the single() declaration, or cancel() the filter's form entirely. Filters that declare no fields are not mounted onto the root form.

The factory returns a FormHarness: the root form plus a mount↔filter map (FilterMount per mounted filter, keyed by the filter's alias). FormHarness::getForm() is the root form. FormHarness::decode() calls each mount's control->decode() and returns the value objects keyed by alias — for a submitted form an entry for every mount (null included), for an unsubmitted one the non-null results (the decoded initial values).

4. Query Building​

When a projector executes the list query, the ListQueryDirector processes each filter on the spec:

  1. The filter's value is determined by its role: with a control, the form's value ($values[$alias] ?? null) — in the interactive list and its aggregation the submission or, unsubmitted, the decoded initial value; in a validation context (readers) there is no map, so the initial value does not apply. Without a control, the fixed Filter::$value, in every context.

  2. The FilterContextFactory creates the FilterContext from an ElementScope and the value: the element's buildContext() runs and translates config + value into one or more predicate calls on the FilterContextBuilderInterface:

    $builder->addPredicate(BooleanPredicate::class, ['field' => 'featured', 'value' => true]);

    Each call's options are resolved against the predicate's own configureOptions() schema and recorded as a Proposition (predicate, resolved options, target alias).

  3. Each Proposition is executed: the predicate's buildConditions(FilterConditionsBuilder $builder, array $options) writes parameterized conditions into a FilterConditionsBuilder scoped to the call's target alias.

Calling $builder->abort() (which throws AbortFilteringException) anywhere in the pipeline short-circuits the list to an empty result set — useful when a required value is missing or invalid. It is a legitimate "matches nothing", distinct from a failure.

5. Target Aliases​

Every predicate call runs against a table alias — by default main, the list's primary table (see SQL Query Struct & Aliasing). Elements registered with #[AsFilterElement(isTargeted: true)] respect the target alias configured on the filter record; a programmatic filter can force one with Filter::withTargetAlias('my_alias'). In the backend, DcaContext::getTargetTable() resolves the same aliasing so DCA option lookups match the queried table.

6. Failure Handling​

Failures follow the two scopes of a filter: the element config, a stored control and a fixed value bound the whole list; an initial value and a control's form field only bound the initial display.

What failsBehaviour
A filter's element cannot be built, with or without a control — unknown element type, broken element config (including a missing required config key and a transformer that throws on the stored record) — or a filter without a control has an unreadable stored (fixed) value or fails at query timeFail closed: the list as a whole fails; error logged
A value provider throws anything other than a FilterExceptionTreated like a FilterException (logged as an error): fail closed without a control, the value dropped with one. A framework FlareException (a wiring error, e.g. an element lacking the port its provider needs) fails the list
A FilterCollectedEvent listener throws a FlareExceptionFail closed: the list fails; any other exception of a listener fails the request
Two published filters of a list share an aliasFail closed: the second one is recorded as a list failure instead of replacing the first; error logged
A filter's stored control cannot be built — its control names no control registered for the element's value class (e.g. one of an uninstalled bundle), or its control config is brokenFail closed: the editor chose the control, so the filter must not apply without it; error logged
A controlled filter's initial value is unreadable (any FilterException from the value provider)Fail open: the control is kept, the value dropped; warning logged
A controlled filter fails at query time with a non-null valueRetried once with value null. Success: only the value is dropped (warning). Failure, or the value was already null: the element config is broken and bounds the whole list — fail closed
A control cannot build its form — buildForm() throws, a FilterControlBuildFormEvent listener throws a FlareException, or the filter's alias is no valid form nameFail closed: the list fails; the form still renders the other filters; error logged
A control's hydrate() throwsFail open: the filter is mounted without its initial value; warning logged
A control's decode() throwsThe filter contributes no value. A submitted field gets an "invalid value" form error, so the form is invalid like any other invalid input and the list renders empty; a failed initial value is only dropped

The failures known before any conditions are built — those recorded while the list was built and those of controls that cannot build their form — win over an invalid submission. An invalid submission otherwise shows no entries and runs no count query. abort() in an element means only "this filter legitimately matches nothing", never a broken configuration.

\Errors (programming errors) are never wrapped or retried. The transformer resolvers are the one deviation: they wrap a \ValueError from casting a stale stored record like any other broken element config. Stale references (a stored choice whose record was deleted) are not failures: they match nothing and, for whitelisted filters, drop out of the intersection.

A failed list is not an empty list:

  • List view: the view exposes flare_list.failed; the template renders the translated list.failed message ("the list could not be created") instead of entries, empty-result text and pagination. The HTTP status stays 200; the filter form may still render.
  • Reader: membership cannot be decided, so the entry is not rendered and the reader answers 500.
  • Debug mode rethrows the exception.

AbortFilteringException keeps meaning "this filter legitimately matches nothing".

Backend. One inspector reports every issue of a list's filters once — with an icon on the filter's row in the list's filter overview, a message on its edit form, and a count above the overview:

IssueIconRaised when
Programming errorbug (red)building or checking the filter throws an \Error; caught (and logged as critical) outside debug mode only, in debug mode it escapes
Duplicate aliascopy-x (red)the filter shares its alias with other filters, see below
Brokenbone-fracture (red)the filter cannot be created, or its conditions cannot be built with its default value (the fixed value without a control, null with one)
Ineffectiveengine (yellow)the element explains why the filter restricts nothing (IneffectiveFilterContract)

Every saved filter is inspected, published or not; an unpublished filter's icon is muted and its issues never count above the overview, which counts published filters and those added by the list driver. Duplicate aliases: two or more published filters sharing an alias block the list, and all filters of that alias are marked; with one published filter only the unpublished ones are marked (they would block the list once published); with none, none is. Failures are recorded on the ListSpec as FilterFailure objects (source, message, exception).

Which exception to throw. FilterException (and its subclass UnreadableValueException) is public API: filter components — elements, controls, value providers and capability ports, bundled and userland alike — throw it to report a filter that cannot work with its config, data or value; FLARE adds the filter's source. Where an editor may see the message — e.g. while the backend loads the options of a choice element's value field — the component translates it itself; elements extending AbstractFilterElement use $this->trans() (flare domain). Every filter hook catches any \Exception and handles it as the table above describes, but only a FilterException counts as expected (warning logged). Any other exception is unexpected: it is logged as an error, and the control hooks rethrow it, wrapped in a FilterException, in debug mode, so a broken control is not silently dropped during development. FlareException and the other exceptions in HeimrichHannot\FlareBundle\Exception are thrown by FLARE itself; catch them, but don't throw them. An element aborts with $builder->abort(), not by throwing AbortFilteringException.