Skip to content

Debugging filters

A filter form is silent by design: a field that produces no condition simply adds nothing to the query builder. That is convenient until a filter "does nothing" and you have to guess why.

Every call to FilterBuilderUpdater::addFilterConditions() therefore builds a FilterExplanation: one entry per walked field, telling you the DQL field it targeted, the extracted values, the event that was dispatched and what came out of it.

Web profiler

When kernel.debug is true, the bundle registers a data collector. The web debug toolbar then shows the number of applied conditions, and turns yellow as soon as a submitted field was silently ignored or a filter state could not be stored.

The Form filter panel lists, for each addFilterConditions() call: the root alias, the joins declared through the add_shared option, the condition tree, the resulting DQL with its bound parameters, and one row per field with its outcome:

OutcomeMeaning
appliedThe field produced a condition, which was added to the condition tree.
no_conditionA listener (or an apply_filter callable) ran but returned nothing — usually an empty value.
no_listenerNo listener is registered for the event of this field: the submitted value is silently ignored.
disabledThe field has 'apply_filter' => false.

Fixing a no_listener field

The panel shows the event name that found no listener, for instance spiriit_form_filter.apply.orm.textarea. Three ways to fix it:

  • use one of the provided filter types (TextFilterType, NumberFilterType, …) instead of the plain Symfony type;
  • register your own listener on the event shown in the panel (see Create your own filter type);
  • set the apply_filter option on the field to build the condition yourself.

The Persistence section

The panel also reports what the state storage was asked to keep during the request, as soon as a filter form uses the filter_persistence option: the storage behind FilterStateStorageInterface, the configured reset parameter, and one row per operation with the values that went in or came out.

OutcomeMeaning
savedThe submitted state was handed to the storage, which kept it.
not_storedThe state was handed to the storage, which kept nothing.
restoredA stored state was found and re-submitted to the form.
nothing_storedThe storage held no state for this form.
clearedThe state was dropped: the reset parameter was in the query string, or a restored state turned out to be invalid.

A not_stored row answers the "why is my filter not remembered?" question: SessionFilterStateStorage never starts a session, so a visitor who has none — or a request on a stateless route — gets no persistence. The storage is read back after each write instead of being trusted, so a state is never reported as saved when nothing was actually kept.

The tracing is done by TraceableFilterStateStorage, which decorates FilterStateStorageInterface when kernel.debug is true: your own storage is traced the same way.

Disabling the collector

The collector is only registered when kernel.debug is true. To remove it in a debug environment as well, drop its definition in a compiler pass:

php
<?php
// src/DependencyInjection/Compiler/RemoveFilterCollectorPass.php
namespace App\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class RemoveFilterCollectorPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $container->removeDefinition('spiriit_form_filter.data_collector');
    }
}

The spiriit_filter.applied event

The explanation is published through the spiriit_filter.applied event (FilterEvents::APPLIED), dispatched once per addFilterConditions() call, after the conditions have been applied to the query builder. This is what the data collector listens to — and you can listen to it too, for instance to log the fields nobody handled:

php
<?php
// src/EventListener/FilterWarningListener.php
namespace App\EventListener;

use Psr\Log\LoggerInterface;
use Spiriit\Bundle\FormFilterBundle\Event\FilterAppliedEvent;
use Spiriit\Bundle\FormFilterBundle\Event\FilterEvents;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: FilterEvents::APPLIED)]
class FilterWarningListener
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    public function __invoke(FilterAppliedEvent $event): void
    {
        $explanation = $event->getExplanation();

        foreach ($explanation->withoutListener() as $field) {
            $this->logger->warning('No listener for the filter field "{field}" (event "{event}").', [
                'field' => $field->path,
                'event' => $field->eventName,
            ]);
        }
    }
}

FilterAppliedEvent gives access to the query builder (getQueryBuilder()) and to the explanation (getExplanation()).

FilterExplanation is countable and iterable over its fields, and exposes:

Property or methodDescription
formName, formTypeName and FQCN of the filter form.
rootAliasAlias the conditions were built on.
fieldsThe FieldExplanation list, in the order the fields were walked.
conditionTreeThe ConditionNodeInterface tree the conditions were mapped on.
joinsThe relation => alias map built from the add_shared options.
applied(), withoutListener(), byOutcome()Filter the fields by outcome.
hasWarnings()True as soon as one field found no listener.

FieldExplanation exposes:

Property or methodDescription
pathComplete field name, including the root form (item_filter.options.label).
nameName the condition is mapped under in the condition tree (options.label).
formType, blockPrefixFQCN and block prefix of the field type.
fieldTargeted DQL field (opt.label).
valuesValues extracted from the form, plus alias and the filter_options.
eventNameDispatched event, or null when an apply_filter callable was used.
outcomeA FieldOutcome case: Applied, NoCondition, NoListener or Disabled.
conditionThe produced ConditionInterface, or null.
isApplied(), isDisabled(), hasListener()Shortcuts on the outcome.

Built and maintained by Spiriit — released under the MIT License.