From cc6489ea536bb7897917ea7cd7d9e89c7e5fbca6 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 2 Oct 2026 18:10:51 +0200 Subject: [PATCH 1/5] fix(audit): log many-to-many collection changes on summits The four excluded_categories_for_* collections and registration_companies now report added and removed ids through SummitAuditLogFormatter instead of being dropped (ADR-004). --- .../SummitAuditLogFormatter.php | 4 + .../SummitManyToManyAuditLogFormatterTest.php | 149 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/OpenTelemetry/Formatters/SummitManyToManyAuditLogFormatterTest.php diff --git a/app/Audit/ConcreteFormatters/SummitAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SummitAuditLogFormatter.php index 6531556ee..ed3daa078 100644 --- a/app/Audit/ConcreteFormatters/SummitAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SummitAuditLogFormatter.php @@ -57,6 +57,10 @@ public function format($subject, array $change_set): ?string $id, $this->getUserInfo() ); + + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + return $this->formatManyToManyCollectionChange($change_set, sprintf("Summit '%s' (%s)", $name, $id)); } } catch (\Exception $ex) { Log::warning("SummitAuditLogFormatter error: " . $ex->getMessage()); diff --git a/tests/OpenTelemetry/Formatters/SummitManyToManyAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/SummitManyToManyAuditLogFormatterTest.php new file mode 100644 index 000000000..29c0fda22 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/SummitManyToManyAuditLogFormatterTest.php @@ -0,0 +1,149 @@ +makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('excluded_categories_for_accepted_presentations', PresentationCategory::class, [10, 11], [11, 12]), + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " excluded_categories_for_accepted_presentations (PresentationCategory) updated: Added IDs: [12], Removed IDs: [10] by user ", + $result + ); + } + + public function testUpdateEventReportsAddedIds(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('registration_companies', Company::class, [], [5, 6]), + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " registration_companies (Company) updated: Added IDs: [5,6] by user ", + $result + ); + } + + public function testDeleteEventReportsRemovedIdsFromPayload(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('excluded_categories_for_accepted_presentations', PresentationCategory::class, [], []), + 'deleted_ids' => [3, 4], + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " excluded_categories_for_accepted_presentations (PresentationCategory) deleted: Removed IDs: [3,4] by user ", + $result + ); + } + + public function testUpdateEventWithoutDiffIsSuppressed(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('registration_companies', Company::class, [3], [3]), + ]); + + $this->assertNull($result); + } + + public function testDeleteEventWithNothingToRemoveIsSuppressed(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('registration_companies', Company::class, [], []), + 'deleted_ids' => [], + ]); + + $this->assertNull($result); + } + + public function testManyToManyEventWithoutCollectionReturnsNull(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $this->assertNull($formatter->format($this->makeSubject(), [])); + } + + public function testManyToManyEventWithInvalidSubjectReturnsNull(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $this->assertNull($formatter->format(new \stdClass(), [ + 'collection' => $this->makeCollection('excluded_categories_for_accepted_presentations', PresentationCategory::class, [], [7]), + ])); + } + + private function makeFormatter(string $eventType): SummitAuditLogFormatter + { + $formatter = new SummitAuditLogFormatter($eventType); + $formatter->setContext(AuditContextBuilder::default()->build()); + return $formatter; + } + + private function makeCollection(string $field, string $targetEntity, array $snapshotIds, array $currentIds) + { + return PersistentCollectionTestHelper::buildManyToManyCollection( + Summit::class, + $field, + $targetEntity, + $snapshotIds, + $currentIds + ); + } + + private function makeSubject(): Summit + { + $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getId')->andReturn(3); + $summit->shouldReceive('getName')->andReturn('Test Summit'); + + return $summit; + } +} From 7802be746527b5fd6f49e103d7cbe19000156009 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 2 Oct 2026 18:10:51 +0200 Subject: [PATCH 2/5] fix(audit): log many-to-many collection changes on sponsors members now reports added and removed ids through SponsorAuditLogFormatter instead of being dropped (ADR-004). --- .../SponsorAuditLogFormatter.php | 4 + ...SponsorManyToManyAuditLogFormatterTest.php | 158 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/OpenTelemetry/Formatters/SponsorManyToManyAuditLogFormatterTest.php diff --git a/app/Audit/ConcreteFormatters/SponsorAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SponsorAuditLogFormatter.php index c58859947..90d48bbfd 100644 --- a/app/Audit/ConcreteFormatters/SponsorAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SponsorAuditLogFormatter.php @@ -65,6 +65,10 @@ public function format($subject, array $change_set): ?string $summit_name, $this->getUserInfo() ); + + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + return $this->formatManyToManyCollectionChange($change_set, sprintf("Sponsor (%s) for Company '%s' in Summit '%s'", $id, $company_name, $summit_name)); } } catch (\Exception $ex) { Log::warning("SponsorAuditLogFormatter error: " . $ex->getMessage()); diff --git a/tests/OpenTelemetry/Formatters/SponsorManyToManyAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/SponsorManyToManyAuditLogFormatterTest.php new file mode 100644 index 000000000..5716cc490 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/SponsorManyToManyAuditLogFormatterTest.php @@ -0,0 +1,158 @@ +makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('members', Member::class, [10, 11], [11, 12]), + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " members (Member) updated: Added IDs: [12], Removed IDs: [10] by user ", + $result + ); + } + + public function testUpdateEventReportsAddedIds(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('members', Member::class, [], [5, 6]), + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " members (Member) updated: Added IDs: [5,6] by user ", + $result + ); + } + + public function testDeleteEventReportsRemovedIdsFromPayload(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('members', Member::class, [], []), + 'deleted_ids' => [3, 4], + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " members (Member) deleted: Removed IDs: [3,4] by user ", + $result + ); + } + + public function testUpdateEventWithoutDiffIsSuppressed(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('members', Member::class, [3], [3]), + ]); + + $this->assertNull($result); + } + + public function testDeleteEventWithNothingToRemoveIsSuppressed(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('members', Member::class, [], []), + 'deleted_ids' => [], + ]); + + $this->assertNull($result); + } + + public function testManyToManyEventWithoutCollectionReturnsNull(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $this->assertNull($formatter->format($this->makeSubject(), [])); + } + + public function testManyToManyEventWithInvalidSubjectReturnsNull(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $this->assertNull($formatter->format(new \stdClass(), [ + 'collection' => $this->makeCollection('members', Member::class, [], [7]), + ])); + } + + private function makeFormatter(string $eventType): SponsorAuditLogFormatter + { + $formatter = new SponsorAuditLogFormatter($eventType); + $formatter->setContext(AuditContextBuilder::default()->build()); + return $formatter; + } + + private function makeCollection(string $field, string $targetEntity, array $snapshotIds, array $currentIds) + { + return PersistentCollectionTestHelper::buildManyToManyCollection( + Sponsor::class, + $field, + $targetEntity, + $snapshotIds, + $currentIds + ); + } + + private function makeSubject(): Sponsor + { + $company = Mockery::mock(Company::class); + $company->shouldReceive('getName')->andReturn('Acme'); + + $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getName')->andReturn('Test Summit'); + + $sponsor = Mockery::mock(Sponsor::class); + $sponsor->shouldReceive('getId')->andReturn(17); + $sponsor->shouldReceive('getCompany')->andReturn($company); + $sponsor->shouldReceive('getSummit')->andReturn($summit); + $sponsor->shouldReceive('getOrder')->andReturn(1); + + return $sponsor; + } +} From 517b227b879b828f16842c9006ee2a1e79662b3d Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 2 Oct 2026 18:10:51 +0200 Subject: [PATCH 3/5] fix(audit): log many-to-many collection changes on bookable venue rooms attributes now reports added and removed ids through SummitBookableVenueRoomAuditLogFormatter instead of being dropped (ADR-004). --- ...mmitBookableVenueRoomAuditLogFormatter.php | 4 + ...nueRoomManyToManyAuditLogFormatterTest.php | 158 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/OpenTelemetry/Formatters/SummitBookableVenueRoomManyToManyAuditLogFormatterTest.php diff --git a/app/Audit/ConcreteFormatters/SummitBookableVenueRoomAuditLogFormatter.php b/app/Audit/ConcreteFormatters/SummitBookableVenueRoomAuditLogFormatter.php index a81aa2608..4a45f9054 100644 --- a/app/Audit/ConcreteFormatters/SummitBookableVenueRoomAuditLogFormatter.php +++ b/app/Audit/ConcreteFormatters/SummitBookableVenueRoomAuditLogFormatter.php @@ -69,6 +69,10 @@ public function format($subject, array $change_set): ?string $summit_name, $this->getUserInfo() ); + + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: + case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: + return $this->formatManyToManyCollectionChange($change_set, sprintf("Bookable Venue Room '%s' (%s) in Venue '%s' for Summit '%s'", $name, $id, $venue_name, $summit_name)); } } catch (\Exception $ex) { Log::warning("SummitBookableVenueRoomAuditLogFormatter error: " . $ex->getMessage()); diff --git a/tests/OpenTelemetry/Formatters/SummitBookableVenueRoomManyToManyAuditLogFormatterTest.php b/tests/OpenTelemetry/Formatters/SummitBookableVenueRoomManyToManyAuditLogFormatterTest.php new file mode 100644 index 000000000..9bd88e019 --- /dev/null +++ b/tests/OpenTelemetry/Formatters/SummitBookableVenueRoomManyToManyAuditLogFormatterTest.php @@ -0,0 +1,158 @@ +makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('attributes', SummitBookableVenueRoomAttributeValue::class, [10, 11], [11, 12]), + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " attributes (SummitBookableVenueRoomAttributeValue) updated: Added IDs: [12], Removed IDs: [10] by user ", + $result + ); + } + + public function testUpdateEventReportsAddedIds(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('attributes', SummitBookableVenueRoomAttributeValue::class, [], [5, 6]), + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " attributes (SummitBookableVenueRoomAttributeValue) updated: Added IDs: [5,6] by user ", + $result + ); + } + + public function testDeleteEventReportsRemovedIdsFromPayload(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('attributes', SummitBookableVenueRoomAttributeValue::class, [], []), + 'deleted_ids' => [3, 4], + ]); + + $this->assertStringStartsWith( + self::MESSAGE_PREFIX . " attributes (SummitBookableVenueRoomAttributeValue) deleted: Removed IDs: [3,4] by user ", + $result + ); + } + + public function testUpdateEventWithoutDiffIsSuppressed(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('attributes', SummitBookableVenueRoomAttributeValue::class, [3], [3]), + ]); + + $this->assertNull($result); + } + + public function testDeleteEventWithNothingToRemoveIsSuppressed(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE); + + $result = $formatter->format($this->makeSubject(), [ + 'collection' => $this->makeCollection('attributes', SummitBookableVenueRoomAttributeValue::class, [], []), + 'deleted_ids' => [], + ]); + + $this->assertNull($result); + } + + public function testManyToManyEventWithoutCollectionReturnsNull(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $this->assertNull($formatter->format($this->makeSubject(), [])); + } + + public function testManyToManyEventWithInvalidSubjectReturnsNull(): void + { + $formatter = $this->makeFormatter(IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE); + + $this->assertNull($formatter->format(new \stdClass(), [ + 'collection' => $this->makeCollection('attributes', SummitBookableVenueRoomAttributeValue::class, [], [7]), + ])); + } + + private function makeFormatter(string $eventType): SummitBookableVenueRoomAuditLogFormatter + { + $formatter = new SummitBookableVenueRoomAuditLogFormatter($eventType); + $formatter->setContext(AuditContextBuilder::default()->build()); + return $formatter; + } + + private function makeCollection(string $field, string $targetEntity, array $snapshotIds, array $currentIds) + { + return PersistentCollectionTestHelper::buildManyToManyCollection( + SummitBookableVenueRoom::class, + $field, + $targetEntity, + $snapshotIds, + $currentIds + ); + } + + private function makeSubject(): SummitBookableVenueRoom + { + $venue = Mockery::mock(SummitVenue::class); + $venue->shouldReceive('getName')->andReturn('Main Venue'); + + $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getName')->andReturn('Test Summit'); + + $room = Mockery::mock(SummitBookableVenueRoom::class); + $room->shouldReceive('getId')->andReturn(9); + $room->shouldReceive('getName')->andReturn('Room A'); + $room->shouldReceive('getVenue')->andReturn($venue); + $room->shouldReceive('getSummit')->andReturn($summit); + + return $room; + } +} From efc3468e3245f7e06b5fc0b89a1bccd8005f33e0 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 2 Oct 2026 18:12:12 +0200 Subject: [PATCH 4/5] test(audit): guard configured formatters against dropping many-to-many events Fails when an entity configured in config/audit_log.php owns a many-to-many collection and one of its formatters (default strategy or any route-scoped strategies[] entry) does not handle EVENT_COLLECTION_MANYTOMANY_UPDATE / DELETE. No allowlist. Run against the formatters on main it reports 19 offenders; with the formatters in this stack it reports none. --- .../Audit/ManyToManyAuditCoverageTest.php | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/Unit/Audit/ManyToManyAuditCoverageTest.php diff --git a/tests/Unit/Audit/ManyToManyAuditCoverageTest.php b/tests/Unit/Audit/ManyToManyAuditCoverageTest.php new file mode 100644 index 000000000..49a46d5c3 --- /dev/null +++ b/tests/Unit/Audit/ManyToManyAuditCoverageTest.php @@ -0,0 +1,136 @@ +getManager(); + $offenders = []; + + foreach (config('audit_log.entities') as $entity => $config) { + if (($config['enabled'] ?? true) === false) { + continue; + } + + $fields = $this->getOwningManyToManyFields($em->getClassMetadata($entity)); + if (empty($fields)) { + continue; + } + + foreach ($this->getConfiguredFormatters($config) as $formatter) { + $missing = array_diff(self::REQUIRED_EVENTS, $this->getHandledEvents($formatter)); + if (!empty($missing)) { + $offenders[] = sprintf( + "%s (%s) is formatted by %s, which does not handle %s", + $entity, + implode(', ', $fields), + $formatter, + implode(' / ', $missing) + ); + } + } + } + + $this->assertEmpty( + $offenders, + "Configured entities that own many-to-many collections must have their formatter handle " . + "EVENT_COLLECTION_MANYTOMANY_UPDATE / DELETE, otherwise those changes are dropped from the audit log " . + "(see adr/004-m2m-audit-logging-join-table-query.md, 'Adding M2M audit to a new entity'). Offenders:\n" . + implode("\n", $offenders) + ); + } + + /** + * @return string[] owning-side many-to-many association field names + */ + private function getOwningManyToManyFields(ClassMetadata $metadata): array + { + $fields = []; + foreach ($metadata->associationMappings as $field => $mapping) { + $type = is_array($mapping) ? $mapping['type'] : $mapping->type(); + $owning = is_array($mapping) ? $mapping['isOwningSide'] : $mapping->isOwningSide(); + if ($type === ClassMetadata::MANY_TO_MANY && $owning) { + $fields[] = $field; + } + } + return $fields; + } + + /** + * @return string[] the default strategy plus every route-scoped strategies[] formatter + */ + private function getConfiguredFormatters(array $config): array + { + $formatters = []; + if (!empty($config['strategy'])) { + $formatters[] = $config['strategy']; + } + foreach ($config['strategies'] ?? [] as $strategy) { + if (!empty($strategy['formatter'])) { + $formatters[] = $strategy['formatter']; + } + } + return array_values(array_unique($formatters)); + } + + /** + * @return string[] required event constants found in the formatter class, its parents + * below AbstractAuditLogFormatter and the traits they use + */ + private function getHandledEvents(string $formatter): array + { + $source = ''; + $class = new ReflectionClass($formatter); + while ($class && $class->getName() !== AbstractAuditLogFormatter::class) { + $source .= $this->getSourceWithTraits($class); + $class = $class->getParentClass() ?: null; + } + + return array_values(array_filter( + self::REQUIRED_EVENTS, + fn(string $event) => str_contains($source, $event) + )); + } + + private function getSourceWithTraits(ReflectionClass $class): string + { + $source = file_get_contents($class->getFileName()); + foreach ($class->getTraits() as $trait) { + $source .= $this->getSourceWithTraits($trait); + } + return $source; + } +} From d0af19da61d546dcbab1f7a59d483b86b7ad3f32 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 2 Oct 2026 18:12:12 +0200 Subject: [PATCH 5/5] docs(adr): list entities with M2M audit support and the coverage guard Extends the 'Current entities with M2M audit support' list and replaces the paragraph saying other configured entities still drop the events with a description of ManyToManyAuditCoverageTest. --- adr/004-m2m-audit-logging-join-table-query.md | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/adr/004-m2m-audit-logging-join-table-query.md b/adr/004-m2m-audit-logging-join-table-query.md index 426be6229..c46c607f8 100644 --- a/adr/004-m2m-audit-logging-join-table-query.md +++ b/adr/004-m2m-audit-logging-join-table-query.md @@ -414,26 +414,14 @@ case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: To add M2M audit support for a new entity: -1. Add a combined `EVENT_COLLECTION_MANYTOMANY_UPDATE`/`DELETE` case to the entity's formatter's `switch`: +1. Add a combined `EVENT_COLLECTION_MANYTOMANY_UPDATE`/`DELETE` case to the entity's formatter's `switch`, routing both events to the shared base method with the entity wording as the label: ```php case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE: case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE: - return $this->handleEntityManyToManyCollection($change_set, $id, $name); + return $this->formatManyToManyCollectionChange($change_set, sprintf("Entity '%s' (%s)", $name, $id)); ``` -2. Implement the private handler following the two-step pattern: - ```php - private function handleEntityManyToManyCollection(array $change_set, $id, $name): ?string - { - $metadata = $this->handleManyToManyCollection($change_set); - if (!$metadata) return null; - $collectionData = $this->processCollection($metadata); - if (!$collectionData) return null; - return $this->event_type === IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE - ? $this->formatManyToManyDelete($collectionData, $id, $name) - : $this->formatManyToManyUpdate($collectionData, $id, $name); - } - ``` -3. Implement private `formatManyToManyUpdate()` and `formatManyToManyDelete()` with entity-specific message formatting. +2. Nothing else is needed in the formatter. `AbstractAuditLogFormatter::formatManyToManyCollectionChange(array $change_set, string $label): ?string` runs the two-step pipeline (`handleManyToManyCollection()` then `processCollection()`), picks update or delete from `$this->event_type`, and builds the message `