fix(DynamicContentSubscriber): complete Mautic 5 migration of filter evaluation#381
fix(DynamicContentSubscriber): complete Mautic 5 migration of filter evaluation#381edouard-mangel wants to merge 23 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR completes the Mautic 5 migration for dynamic content filter evaluation by removing Mautic 4-era trait/query-builder logic from DynamicContentSubscriber and delegating evaluation to a matcher, with updated unit test coverage and an ADR documenting the migration approach.
Changes:
- Refactors
DynamicContentSubscriber::evaluateFilters()to remove trait/DBAL-based evaluation and delegate matching to aContactFilterMatcher. - Rewrites
DynamicContentSubscriberTestto cover the new collaborator-driven behavior and updated early-exit conditions. - Adds an ADR documenting the iterative migration strategy and local DDEV constraints.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
EventListener/DynamicContentSubscriber.php |
Removes old trait-based evaluation and routes dynamic content filter evaluation through a matcher + CO-filter detection. |
Tests/Unit/EventListener/DynamicContentSubscriberTest.php |
Updates unit tests to reflect the new control flow and collaborator usage. |
docs/adr/0001-mautic5-migration-strategy.md |
Documents migration decisions, rationale, and DDEV/symlink constraints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
…evaluation The 5.x branch left DynamicContentSubscriber in the old Mautic 4 state: traits (MatchFilterForLeadTrait, DbalQueryTrait) that no longer exist in mautic/core-lib ^5.0, and a QueryBuilder-based filter evaluation path. Changes: - Remove MatchFilterForLeadTrait and DbalQueryTrait (removed from core in 5.x) - Remove QueryFilterHelper and LoggerInterface from constructor - Wire ContactFilterMatcher as the delegate in evaluateFilters() - Simplify hasCustomObjectFilters() — returns true on first non-throwing filter - Rewrite DynamicContentSubscriberTest: correct collaborators, 6 tests / 21 assertions - Add docs/adr/0001-mautic5-migration-strategy.md Verified on Mautic 5.2.9 / Symfony 5.4 / PHP 8.3 via DDEV: - cache:clear succeeds - mautic:plugins:reload installs the plugin (13 DB tables created) - Custom Objects UI fully functional at /s/custom/object/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
a331ffe to
f49fc5d
Compare
…ill trait directly The class_alias trick (selecting between Mautic core MatchFilterForLeadTrait and the polyfill at runtime) is not statically analysable by PHPStan, causing 8 false-positive errors. Since this branch targets Mautic 5 where the core trait no longer has transformFilterDataForLead(), the polyfill branch always executes — making the class_alias logic dead code. - Remove class_alias block and Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait import - Use MatchFilterForLeadTraitPolyfill directly with a named alias - Add 2 legitimate PHPStan baseline entries (MAUTIC_TABLE_PREFIX global constant and transformFilterDataForLead trait-override detection limitation) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
escopecz
left a comment
There was a problem hiding this comment.
Can you mention in the PR description what error is this fixing? I'm not sure I understand the changes. The error message would help.
DynamicContentSubscriber and ContactFilterMatcher are already discovered by Config/services.php via ->load(). Add ->bind() for the scalar $leadCustomItemFetchLimit parameter so autowiring can resolve it without explicit config.php entries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace global ->bind() in services.php defaults with a per-service #[Autowire(param: ...)] attribute on ContactFilterMatcher, following the pattern used in Mautic 5 core (JsController, PluginDatabase, etc.).
Covers: no CO filters, NotFoundException/InvalidCustomObjectFormatListException handling, no linked items, name match/mismatch, multi-item match, custom item caching per object+lead, company enrichment, tag enrichment.
…ems linked When a contact has no linked custom items, the lead value array is empty and the foreach never executes, leaving the group result as false. For the 'empty' operator this is semantically wrong — no items means the field IS empty, so the condition should match.
…ing in services.php The #[Autowire] attribute is not reliably processed in Mautic 5.1 environments, causing a container compilation failure. Use a specific service definition with ->arg() in services.php instead — this is scoped to ContactFilterMatcher only and works across all supported Mautic/Symfony versions.
… non-capturing catches)
…ataForLead methods
|
Hi @escopecz — just a heads-up that the PR description has been updated to reflect the accurate root cause (the |
|
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Please remove the |
|
Hello Team :) I'm sorry, I got the notification that this MR is stale. Is there any modification still needed ? I had the impression that I had done all you asked. Do you need anything from me ? |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $event->stopPropagation(); | ||
| $event->setIsMatched($this->contactFilterMatcher->match( | ||
| $event->getFilters(), | ||
| $event->getContact()->getProfileFields() |
There was a problem hiding this comment.
ContactFilterMatcher::match() expects the lead array to contain an id (it immediately reads $lead['id']). Lead::getProfileFields() typically does not include the entity id, so this call risks an undefined-index notice (and subsequent fatal errors) during dynamic content evaluation. Pass the id explicitly (e.g., merge ['id' => $event->getContact()->getId()] into the profile fields) so matching and tag/company preloading can work reliably.
| $event->getContact()->getProfileFields() | |
| array_merge( | |
| ['id' => $event->getContact()->getId()], | |
| $event->getContact()->getProfileFields() | |
| ) |
| public function match(array $filters, array $lead, bool &$hasCustomFields = false): bool | ||
| { | ||
| $leadId = (string) $lead['id']; | ||
| $customFieldValues = $this->getCustomFieldDataForLead($filters, $leadId); | ||
|
|
||
| if (!$customFieldValues) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
match() reads $lead['id'] unconditionally. If the lead array comes from profile fields/preview data and lacks an id, this will raise an undefined-index notice and can break filter evaluation. Add an early guard (similar to the trait’s empty($lead['id']) check) and return false when no lead id is available.
| private function buildFilters(): array | ||
| { | ||
| return new ContactFiltersEvaluateEvent( | ||
| [ | ||
| 'custom_field_1' => [ | ||
| 'type' => CustomFieldFilterQueryBuilder::getServiceId(), | ||
| 'table' => 'custom_field_text', | ||
| 'field' => 'cfwq_1', | ||
| 'foreign_table' => 'custom_objects', | ||
| ], | ||
| 'custom_item_1' => [ | ||
| 'type' => CustomItemNameFilterQueryBuilder::getServiceId(), | ||
| 'table' => 'custom_field_text', | ||
| 'field' => 'cowq_2', | ||
| 'foreign_table' => 'custom_objects', | ||
| ], | ||
| return [ | ||
| 'custom_field_1' => [ | ||
| 'type' => CustomFieldFilterQueryBuilder::getServiceId(), | ||
| 'table' => 'custom_field_text', | ||
| 'field' => 'cfwq_1', | ||
| 'foreign_table' => 'custom_objects', | ||
| ], | ||
| new Lead() | ||
| ); | ||
| 'custom_item_1' => [ | ||
| 'type' => CustomItemNameFilterQueryBuilder::getServiceId(), | ||
| 'table' => 'custom_field_text', | ||
| 'field' => 'cowq_2', | ||
| 'foreign_table' => 'custom_objects', | ||
| ], | ||
| ]; |
There was a problem hiding this comment.
buildFilters() constructs filter entries with keys like table/foreign_table/cfwq_1, but ContactFilterMatcher (and the segment matching logic) expects segment-style filters containing keys such as object, field (e.g., cmf_1/cmo_1), operator, filter, and glue (see TokenSubscriber’s matching logic). As written, this unit test can’t catch real integration issues because the filter shape differs from what the matcher can actually process.
| $this->contactFilterMatcher->expects($this->once()) | ||
| ->method('match') | ||
| ->with($this->buildFilters(), ['email' => 'test@example.com']) | ||
| ->willReturn(true); |
There was a problem hiding this comment.
This expectation asserts that DynamicContentSubscriber calls the matcher with only ['email' => ...]. However, ContactFilterMatcher::match() requires id in the lead array (it uses it to load custom items/tags/companies). Update the test to include the contact id in the lead data passed to match() once the subscriber is fixed to provide it.
| <?php | ||
|
|
||
| namespace MauticPlugin\CustomObjectsBundle\Polyfill\EventListener; | ||
|
|
||
| use Mautic\LeadBundle\Entity\LeadListRepository; | ||
| use Mautic\LeadBundle\Exception\OperatorsNotFoundException; | ||
| use Mautic\LeadBundle\Segment\OperatorOptions; |
There was a problem hiding this comment.
This new polyfill file is missing declare(strict_types=1);, while the rest of the codebase appears to consistently enable strict types. Adding it helps keep type behavior consistent across the bundle (especially important here given extensive scalar comparisons/casts).
| protected function matchFilterForLead(array $filter, array $lead): bool | ||
| { | ||
| if (empty($lead['id'])) { | ||
| // Lead in generated for preview with faked data |
There was a problem hiding this comment.
Typo in comment: "Lead in generated for preview with faked data" → "Lead is generated for preview with faked data".
| // Lead in generated for preview with faked data | |
| // Lead is generated for preview with faked data |
|
Hi, sorry for the delay. We'll need to get some reviewers and testers to this PR. The struggle to perform the quality checks was always a struggle for OSS projects. It's been multiplied in the recent years with AI where writing the code is cheap but assessing the quality isn't something that AI can do so it would be trustworthy. You can make maintainers to trust your changes more if your changes are covered by functional tests. There is only a unit test in this PR. Also, there shouldn't be any additions to I'm still not really sure about duplicating the trait from Mautic. But that would require me to do an extensive research that I cannot spend the time on. I'd look if we could update the trait in Mautic so it would also work for this plugin. Or fire a new event that this plugin would enable to hook into the logic and update it. Because with this duplication if a change is made in the original then it won't be made in this copy and more bugs will happen. |
…ith FilterEvaluator service - Add Helper/FilterEvaluator.php: standalone service consolidating three divergent copies of filter evaluation logic (TokenSubscriber, ContactFilterMatcher, and the polyfill trait) into one authoritative implementation with multi-value any-match semantics for custom_object fields - Remove Polyfill/EventListener/MatchFilterForLeadTrait.php (236 lines deleted) - Remove dead transformFilterDataForLead() from ContactFilterMatcher - Inject FilterEvaluator into ContactFilterMatcher and TokenSubscriber - Register ContactFilterMatcher explicitly in config.php to resolve int $leadCustomItemFetchLimit DI argument (excluded from services.php auto-discovery to avoid conflict) - Remove 5 phpstan-baseline suppressions that covered dead code - Replace trivial DynamicContentSubscriberTest with 8 MauticMysqlTestCase functional tests covering operators, AND/OR glue, multi-item any-match, empty operator, and subscriber skip conditions - Update TokenSubscriberTest to include FilterEvaluator mock
…Matcher in config.php, add class-name aliases in services.php for autowiring
…tch loop - DynamicContentSubscriber: pass array_merge(['id' => $contact->getId()], $contact->getProfileFields()) so ContactFilterMatcher can find 'id' (Lead::getProfileFields() does not include the entity id property) - FilterEvaluator: fix any-match semantics — previous loop broke on first non-match (null !== false → break), preventing evaluation of subsequent linked custom items
…or DynamicContentSubscriber
- Add Tests/Unit/Helper/FilterEvaluatorTest: 36 tests covering all operators, type coercions, AND/OR group logic, custom-object any-match semantics, and edge cases (missing id, empty filters, scalar-to-array normalisation) - DynamicContentSubscriberTest: add testRegexpOperator, testNumberFieldComparisons, testOrFiltersFirstGroupPassesSecondFails - DynamicContentSubscriberTest: simplify testAlreadyEvaluatedEventIsSkipped — removes unnecessary DB fixture creation (subscriber bails out before DB access)
…unctional test - FilterEvaluator: add 'int' case alongside 'number' in coerceTypes switch (CustomObjectsBundle IntType uses key 'int', Mautic lead fields use 'number') - Functional test: use 'int' as the type for int field filters (matches the actual type key registered by IntType, which QueryFilterFactory validates) - Unit test: add 'int' coercion assertion alongside existing 'number' test
|
Hi @escopecz — thank you for the detailed feedback, and for taking the time despite being stretched on reviewer capacity. I really appreciate the transparency about the challenges OSS maintenance brings, especially in the current AI-assisted landscape. I've addressed all three concerns: 1. Functional tests — The PR now includes a full 2. phpstan-baseline additions — The 5 baseline suppressions from the previous iteration have been removed. The baseline is now smaller than before this PR. 3. Trait duplication — The polyfill trait has been deleted entirely. It is replaced by a standalone The fix for the CI is green on PHP 8.0 / MySQL / Mautic 5.1: https://github.com/WebAnyOne/mc-cs-plugin-custom-objects/actions/runs/24459717499 Happy to make any further adjustments, answer questions, or provide additional context to help reviewers gain confidence in the change. Let me know what would be most useful. |
|
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Please remove the |
Context
This PR fixes a fatal
TypeErrorthat occurs when evaluating Dynamic Web Content with custom object filters in Mautic 5.Root cause
DynamicContentSubscriberusedDbalQueryTrait::executeSelect()which expects aDoctrine\DBAL\Query\QueryBuilder. ButQueryFilterFactory::configureQueryBuilderFromSegmentFilter()can return aUnionQueryContainerfor custom field filters — causing a fatalTypeErrorat runtime.The branch also had three diverging copies of
matchFilterForLead()logic (polyfill trait,TokenSubscriber,ContactFilterMatcher), none of which correctly handled multi-value custom object fields where$lead['cmf_X']is an array of values (one per linked item).Changes
Helper/FilterEvaluator(new service)Standalone service owning all DWC filter evaluation logic:
custom_objectfields: iterates the array of linked item values — a filter matches if any item satisfies the condition=,!=,gt,gte,lt,lte,empty,!empty,like,!like,in,!in,regexp,!regexp,startsWith,endsWith,containsboolean,datetime/time,tags/select/multiselect,number/intHelper/ContactFilterMatcherMatchFilterForLeadTraitPolyfilland deadtransformFilterDataForLead()LeadListRepositorydependency (was only used by the now-deleted trait)FilterEvaluatorand delegates evaluation to itEventListener/DynamicContentSubscriberContactFilterMatcher::match()as the delegate inevaluateFilters()array_merge(['id' => $contact->getId()], $contact->getProfileFields())—getProfileFields()does not include the entityidEventListener/TokenSubscribermatchFilterForLeadInCustomObject()— delegates to injectedFilterEvaluatorPolyfill/EventListener/MatchFilterForLeadTrait— deletedphpstan-baseline.neonTests
Tests/Unit/Helper/FilterEvaluatorTest(new — 36 tests, no DB)boolean,datetime,multiselect,int/number)id, empty filter list, unknown field)OperatorsNotFoundExceptionfor unknown operatorsTests/Functional/EventListener/DynamicContentSubscriberTest(rewritten — 10 tests, MySQL)testVariousOperators— all string/date operators against real DB data (20+ assertions)testAndFiltersAllMustMatch/testAndFiltersOneFailsNoMatchtestOrFiltersSecondGroupMatches/testOrFiltersFirstGroupPassesSecondFailstestMultipleLinkedItemsAnyMatchWinstestEmptyOperatorMatchesContactWithNoLinkedItemstestRegexpOperatortestNumberFieldComparisons— gt/gte/lt/lte/= on int fieldstestAlreadyEvaluatedEventIsSkippedtestEvaluateFiltersSkipsEventWithNoCustomObjectFiltersVerification
CI passes on PHP 8.0 / MySQL / Mautic 5.1: https://github.com/WebAnyOne/mc-cs-plugin-custom-objects/actions/runs/24459717499