Skip to content

FeatureSet Event dispatch architecture

domenico edited this page May 21, 2026 · 1 revision

type: Spec status: current date: 2026-05-21 author: Domenico Lupinetti branch: context-review

FeatureSet Event Dispatch — Architectural Spec

1. Overview

Matecat uses a plugin-based event dispatch system to allow features (plugins) to modify behavior at defined extension points without coupling core code to plugin-specific logic.

The system has two dispatch channels:

Channel Base Class Purpose Handler Contract
Filter FilterEvent Transform data — handler mutates the event's mutable subject void return, mutate event
Run RunEvent Side effects — fire-and-forget notification void return, readonly event

2. Architecture

FeatureSet implements Psr\EventDispatcher\EventDispatcherInterface (PSR-14 standard). A single dispatch() entry point handles both Filter and Run events.

flowchart LR
    CS[Call Site] -->|" dispatch(new Event($data)) "| FS[FeatureSet]
    FS -->|" is FilterEvent/RunEvent?"| CHK{Event type}
    CHK -->|"yes"| HN["route by hookName()"]
    CHK -->|"no (external)"| CN["derive hook from class name"]
    HN -->|" hookName($event) "| P[Plugins]
    CN -->|" hookName($event) "| P
    P -->|" mutated event "| FS
    FS -->|" return event "| CS
Loading

Dispatch algorithm (inside FeatureSet):

function dispatch(object $event): object
    if ($event instanceof FilterEvent || $event instanceof RunEvent):
        hookName = $event::hookName()          ← convention-based, zero reflection
        rethrow = true                          ← domain exceptions propagate
    else:
        hookName = lcfirst(event class - 'Event')  ← derived for external PSR-14 events
        rethrow = false                         ← all exceptions swallowed

    for each loaded feature:
        $obj = feature->toNewObject()
        if method_exists($obj, hookName):
            try:   $obj->{hookName}($event)
            catch: domain exceptions → rethrow if rethrow, else log
                   other exceptions → always log
    return $event

Key mechanics

  1. PSR-14 complianceFeatureSet implements EventDispatcherInterface. External code (e.g. subfiltering) can dispatch any object through dispatch() without knowing about FilterEvent/RunEvent internals.
  2. Convention-based routing — No explicit registration. For internal events, a plugin handles an event by having a public method whose name matches $event::hookName(). For external PSR-14 events, the hook name is derived from the event class name (e.g. MyPipelineEventmyPipeline).
  3. All loaded features are checked — iteration order depends on feature load order (mandatory first, then project-specific, with dependency sorting via sortFeatures()).
  4. Multiple handlers — Multiple plugins can handle the same event. For filters, each handler sees the event as mutated by the previous handler (chain). For run, order is irrelevant (side effects only).
  5. Error isolation — For Filter/Run events, known application exceptions (ValidationError, NotFoundException, AuthenticationError, ReQueueException, EndQueueException) propagate. All other exceptions are logged and swallowed. For external PSR-14 events, ALL exceptions are swallowed.

3. Filter vs Run — Sequence

sequenceDiagram
    participant CS as Call Site
    participant FS as FeatureSet
    participant PA as Plugin A
    participant PB as Plugin B
    Note over CS, PB: Filter Event (data transformation)
    CS ->> FS: dispatch(new FilterEvent($data))
    FS ->> FS: is FilterEvent → hookName(), rethrow=true
    FS ->> PA: hookName($event)
    PA ->> PA: $event->setData(modified)
    FS ->> PB: hookName($event)
    PB ->> PB: $event->setData(further modified)
    FS -->> CS: return $event (mutated by chain)
    Note over CS, PB: Run Event (side effects)
    CS ->> FS: dispatch(new RunEvent($context))
    FS ->> FS: is RunEvent → hookName(), rethrow=true
    FS ->> PA: hookName($event)
    PA ->> PA: perform side effect
    FS ->> PB: hookName($event)
    PB ->> PB: perform side effect
    FS -->> CS: return $event (readonly)
    Note over CS, PB: External PSR-14 Event (subfiltering)
    CS ->> FS: dispatch(new SubfilteringEvent($data))
    FS ->> FS: not Filter/RunEvent → derive hook, rethrow=false
    FS ->> PA: hookName($event)
    FS ->> PB: hookName($event)
    FS -->> CS: return $event (exceptions swallowed)
Loading

4. Event Anatomy

FilterEvent (transforms data)

final class FilterPayableRatesEvent extends FilterEvent
{
    public static function hookName(): string { return 'filterPayableRates'; }

    public function __construct(
        private array $rates,                   // ← mutable subject (setter provided)
        private readonly string $source,        // ← readonly context (getter only)
        private readonly string $target,
    ) {}

    public function getRates(): array { return $this->rates; }
    public function setRates(array $rates): void { $this->rates = $rates; }
    public function getSource(): string { return $this->source; }
    public function getTarget(): string { return $this->target; }
}

Convention: First constructor argument = the mutable subject. Remaining arguments = readonly observation context.

RunEvent (fire-and-forget)

final class JobPasswordChangedEvent extends RunEvent
{
    public static function hookName(): string { return 'jobPasswordChanged'; }

    public function __construct(
        public readonly JobStruct $job,
        public readonly string $oldPassword,
    ) {}
}

Convention: All properties are readonly. Handlers perform side effects, never return values.


5. Handler Contract

A plugin handles an event by defining a public method:

class Uber extends BaseFeature
{
    public function filterPayableRates(FilterPayableRatesEvent $event): void
    {
        $rates = $event->getRates();
        $rates['key'] = 'modified';
        $event->setRates($rates);
    }

    public function jobPasswordChanged(JobPasswordChangedEvent $event): void
    {
        $this->notifyPasswordChange($event->job, $event->oldPassword);
    }
}

Rules:

  • Method name MUST match hookName() exactly (camelCase)
  • Single parameter: the event object
  • Return type: void
  • Mutate the event (for filters), don't return a value

6. Feature Loading & Dispatch Order

flowchart LR
    subgraph Loading Sources
        M["Mandatory<br/>(global, always)"]
        P["Project metadata<br/>(per-request)"]
        U["User features<br/>(owner_features)"]
        F["Forceable<br/>(auto-enabled)"]
    end

    subgraph FeatureSet
        ARR["features[]<br/>(ordered array)"]
        SORT["sortFeatures()<br/>(dependency order)"]
    end

    M --> ARR
    F --> ARR
    P --> ARR
    U --> ARR
    ARR --> SORT
Loading
Source Method When
Mandatory (global) loadFromMandatory() Constructor (always)
Forceable loadForceableProjectFeatures() During loadForProject
Project-specific loadForProject($project) Per-request, from project_metadata.features
User-specific loadFromUserEmail($id_customer) Per-request, from owner_features table

Dispatch iterates $this->features in insertion order after sortFeatures() applies dependency-based ordering.


7. Current Hook Inventory

Filter Events (29)

hookName Event Class Purpose
isAnInternalUser IsAnInternalUserEvent Check if user is internal
outsourceAvailableInfo OutsourceAvailableInfoEvent Filter outsource provider info
projectUrls ProjectUrlsEvent Filter project URL generation
filterCreateProjectFeatures FilterCreateProjectFeaturesEvent Modify features on project creation
encodeInstructions EncodeInstructionsEvent Transform instructions before storage
decodeInstructions DecodeInstructionsEvent Transform instructions after retrieval
filterActivityLogEntry FilterActivityLogEntryEvent Modify activity log entries
filterContributionStructOnSetTranslation FilterContributionStructOnSetTranslationEvent Modify TM contribution on set translation
filterContributionStructOnMTSet FilterContributionStructOnMTSetEvent Modify TM contribution on MT set
filterGetSegmentsResult FilterGetSegmentsResultEvent Modify GetSegments API response
prepareNotesForRendering PrepareNotesForRenderingEvent Transform notes before rendering
filterJobPasswordToReviewPassword FilterJobPasswordToReviewPasswordEvent Map job password to review password
filterRevisionChangeNotificationList FilterRevisionChangeNotificationListEvent Modify revision notification recipients
filterMyMemoryGetParameters FilterMyMemoryGetParametersEvent Modify MyMemory API params
characterLengthCount CharacterLengthCountEvent Custom character counting
injectExcludedTagsInQa InjectExcludedTagsInQaEvent Exclude tags from QA checks
checkTagMismatch CheckTagMismatchEvent Custom tag mismatch logic
checkTagPositions CheckTagPositionsEvent Custom tag position validation
analysisBeforeMTGetContribution AnalysisBeforeMTGetContributionEvent Modify MT request during analysis
filterPayableRates FilterPayableRatesEvent Modify payable rate calculation
wordCount WordCountEvent Custom word count logic
populatePreTranslations PopulatePreTranslationsEvent Control pre-translation population
sanitizeOriginalDataMap SanitizeOriginalDataMapEvent Sanitize XLIFF originalData
correctTagErrors CorrectTagErrorsEvent Auto-correct tag errors
appendFieldToAnalysisObject AppendFieldToAnalysisObjectEvent Add fields to analysis data
handleJsonNotesBeforeInsert HandleJsonNotesBeforeInsertEvent Transform JSON notes on insert
rewriteContributionContexts RewriteContributionContextsEvent Rewrite TM contribution contexts
appendInitialTemplateVars AppendInitialTemplateVarsEvent Add vars to page templates
fromLayer0ToLayer1 FromLayer0ToLayer1Event Customize subfiltering pipeline

Run Events (15)

hookName Event Class Purpose
setTranslationCommitted SetTranslationCommittedEvent After translation committed to DB
postAddSegmentTranslation PostAddSegmentTranslationEvent After segment translation added
chunkReviewUpdated ChunkReviewUpdatedEvent After chunk review record updated
jobPasswordChanged JobPasswordChangedEvent After job password rotation
reviewPasswordChanged ReviewPasswordChangedEvent After review password rotation
projectCompletionEventSaved ProjectCompletionEventSavedEvent After project marked complete
tmAnalysisDisabled TmAnalysisDisabledEvent When TM analysis is disabled
postJobSplitted PostJobSplittedEvent After job split
postJobMerged PostJobMergedEvent After job merge
validateJobCreation ValidateJobCreationEvent Validate before job creation
validateProjectCreation ValidateProjectCreationEvent Validate before project creation
beforeProjectCreation BeforeProjectCreationEvent Hook before project persisted
postProjectCreate PostProjectCreateEvent After project created
filterProjectNameModified FilterProjectNameModifiedEvent After project name changed
alterChunkReviewStruct AlterChunkReviewStructEvent Modify chunk review struct

8. Historical Evolution

timeline
    title Event Dispatch Evolution
    section Phase 1 — String-based (pre-2026)
        Legacy: filter('hookName', $arg1, $arg2)
                : run('hook_name', $arg1)
                : No type safety, mixed case
    section Phase 2 — Typed Events (2026-04)
        Current: dispatch(new Event($data))
                : 20 dead hooks removed
                : All camelCase, typed DTOs
    section Phase 3 — PSR-14 (2026-05, current)
        Current: Implements EventDispatcherInterface
                : FeatureSetInterface deleted
                : Pure router, zero subfiltering imports
Loading

Phase 1: String-based dispatch (pre-2026)

// Untyped, no IDE support
$rates = $this->featureSet->filter('filterPayableRates', $rates, $source, $target);
$this->featureSet->run('job_password_changed', $jStruct, $oldPwd);

// Handler — positional args, return value
public function filterPayableRates(array $rates, string $source, string $target): array {
    return $rates;
}

Problems: No type safety, no IDE navigation, mixed snake_case/camelCase, filter handlers returned values (easy to forget = silent data loss).

Phase 2: Typed Event DTOs (2026-04)

// Typed, IDE-navigable
$event = $this->featureSet->dispatch(new FilterPayableRatesEvent($rates, $source, $target));
$rates = $event->getRates();

// Handler — single event, void return
public function filterPayableRates(FilterPayableRatesEvent $event): void {
    $event->setRates($modifiedRates);
}

Migration: 20 unused hooks removed, 5 renamed to camelCase, filter()/run() string methods deleted. Separate dispatchFilter()/dispatchRun() methods existed temporarily; later unified into a single dispatch().

Phase 3: PSR-14 Standardization (2026-05, current)

What was done:

  • FeatureSetInterface — the 6-method interface from vendor/matecat/subfiltering that leaked Pipeline — was deleted. Zero references remain in the codebase.
  • FeatureSet now implements Psr\EventDispatcher\EventDispatcherInterface (PSR-14).
  • Single entry point: dispatch(object $event): object. No more dispatchFilter()/dispatchRun() distinction at the call site. The event type is checked internally (instanceof FilterEvent/RunEvent) to decide hook-name resolution (convention-based vs class-name-derived) and error-propagation behavior.
  • FeatureSet is a pure event router — zero imports from the subfiltering package. No Pipeline-typed methods. The old customizeFromLayer0ToLayer1 and 5 dead passthrough methods are gone.
  • External code (e.g. subfiltering's MateCatFilter) now dispatches PSR-14 events through the same dispatch() entry point. Hook names for external events are derived from the event class name (e.g. MyPipelineEvent → hook myPipeline). Exceptions from external event handlers are always swallowed.

9. Resolved Coupling: SubFiltering

flowchart LR
    MF["MateCatFilter<br/>(subfiltering)"] -->|" dispatch(new PSR-14 event) "| FS["FeatureSet"]
    FS -->|" derive hook from class name"| EV["Plugin handlers"]
    style FS fill: #dfd, stroke: #0a0
Loading

Current state: SubFiltering uses FeatureSet through the PSR-14 dispatch() entry point. FeatureSet has zero imports from the subfiltering package. The old FeatureSetInterface (6 Pipeline-typed methods) no longer exists. Subfiltering events enter through the same dispatch() method as any other event — FeatureSet derives the hook name from the event class name (e.g. FromLayer0ToLayer1Event → hook fromLayer0ToLayer1Adapter). Exceptions from subfiltering event handlers are swallowed per the external-event error policy.


10. Creating a New Hook — Checklist

Step 1: Create the Event class

<?php
// lib/Model/FeaturesBase/Hook/Event/Filter/MyNewHookEvent.php

/**
 * @see \Controller\API\App\SomeController::someMethod() — dispatch site
 */
final class MyNewHookEvent extends FilterEvent
{
    public static function hookName(): string { return 'myNewHook'; }

    public function __construct(
        private array $data,                    // mutable subject
        private readonly string $context,       // readonly context
    ) {}

    public function getData(): array { return $this->data; }
    public function setData(array $data): void { $this->data = $data; }
    public function getContext(): string { return $this->context; }
}

Step 2: Dispatch

$event = $this->featureSet->dispatch(new MyNewHookEvent($data, $context));
$data = $event->getData();

The single dispatch() method handles both Filter and Run events — no dispatchFilter/dispatchRun distinction at the call site. FeatureSet internally checks instanceof to determine hook-name resolution and error policy.

Step 3: Handle in plugin

public function myNewHook(MyNewHookEvent $event): void
{
    $data = $event->getData();
    $data['extra'] = 'added by plugin';
    $event->setData($data);
}

Rules

Rule Requirement
Naming camelCase. hookName() return = handler method name
Filter events First arg = mutable (has setter). Rest = readonly
Run events All readonly
Handler (EventClass $event): void — mutate event, don't return
One event = one hook No reuse across hooks
Events are final Don't extend concrete events
Location lib/Model/FeaturesBase/Hook/Event/Filter/ or .../Run/
@see Every event points to its dispatch site(s)

IDE Navigation

  • Event → caller: Ctrl+click @see
  • Caller → handlers: Find Usages on Event class
  • Handler → caller: Find Usages on Event in parameter type-hint

Clone this wiki locally