fix(DynamicContentSubscriber): complete Mautic 5 migration of filter evaluation#381
fix(DynamicContentSubscriber): complete Mautic 5 migration of filter evaluation#381edouard-mangel wants to merge 14 commits intoacquia:5.xfrom
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. |
Context
This PR fixes a fatal error that blocks Mautic 5 from booting with the plugin installed. The `5.x` branch still had `DynamicContentSubscriber` in its Mautic 4 form: it used a `QueryBuilder`-based filter evaluation path via `DbalQueryTrait` that is incompatible with Mautic 5 / Doctrine DBAL 3.x.
Root cause
On the `5.x` branch, `DynamicContentSubscriber`:
Changes
`EventListener/DynamicContentSubscriber.php`
`Helper/ContactFilterMatcher.php` (new)
Brings the `ContactFilterMatcher` class from the `staging` branch into `5.x`. This helper was already written as part of the Mautic 5 migration work but was never merged into the `5.x` branch. It fetches custom field data per contact and delegates filter evaluation to the core `MatchFilterForLeadTrait` (extended with custom-object multi-value support).
`Polyfill/EventListener/MatchFilterForLeadTrait.php` (new)
Wraps `\Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait` (which still exists in M5) and extends it with:
`Config/config.php`
`Config/services.php`
`Tests/Unit/EventListener/DynamicContentSubscriberTest.php`
Rewritten to cover the actual collaborators: 6 tests, 21 assertions.
Architecture decision
Why was ContactFilterMatcher not in `5.x` originally?
The `5.x` branch was created from the merged `pr-338` commit which updated the plugin for Mautic 5. However, the `DynamicContentSubscriber` was left in its Mautic 4 state (with the broken trait references), while `ContactFilterMatcher` and its polyfill were developed in the `staging` branch as part of ongoing Mautic 5 work. This PR ports only those two files needed to make `DynamicContentSubscriber` functional.
Why not just replicate the old QueryBuilder approach?
The old approach fails because `configureQueryBuilderFromSegmentFilter()` returns a `UnionQueryContainer` for custom field filters — not a plain `QueryBuilder`. `ContactFilterMatcher` provides the correct Mautic 5 interface and is already used by `CampaignSubscriber` in `staging`.
Why `configureQueryBuilderFromSegmentFilter()` for CO filter detection?
`QueryFilterFactory::configureQueryBuilderFromSegmentFilter()` throws `InvalidSegmentFilterException` for any filter that is not a custom object filter. This makes it the natural gate for detecting CO-relevant filters, consistent with how `CampaignSubscriber` works in this plugin.
Verification
Tested on Mautic 5.2.9 / Symfony 5.4 / PHP 8.3 via DDEV:
```
bin/console cache:clear ✓ (was fatal before)
bin/console mautic:plugins:reload ✓ (13 DB tables created)
bin/console doctrine:mapping:info ✓ (14 CO entities discovered)
Custom Objects UI at /s/custom/object/ ✓ (fully functional)
PHPUnit DynamicContentSubscriberTest ✓ (6/6 tests pass)
```