-
Notifications
You must be signed in to change notification settings - Fork 279
FeatureSet Event dispatch architecture
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 |
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
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
-
PSR-14 compliance —
FeatureSet implements EventDispatcherInterface. External code (e.g. subfiltering) can dispatch any object throughdispatch()without knowing about FilterEvent/RunEvent internals. -
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.MyPipelineEvent→myPipeline). -
All loaded features are checked — iteration order depends on feature load order (mandatory first, then
project-specific, with dependency sorting via
sortFeatures()). - 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).
-
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.
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)
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.
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.
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
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
| 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.
| 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 |
| 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 |
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
// 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).
// 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().
What was done:
-
FeatureSetInterface— the 6-method interface fromvendor/matecat/subfilteringthat leakedPipeline— was deleted. Zero references remain in the codebase. - FeatureSet now implements
Psr\EventDispatcher\EventDispatcherInterface(PSR-14). - Single entry point:
dispatch(object $event): object. No moredispatchFilter()/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 oldcustomizeFromLayer0ToLayer1and 5 dead passthrough methods are gone. - External code (e.g. subfiltering's
MateCatFilter) now dispatches PSR-14 events through the samedispatch()entry point. Hook names for external events are derived from the event class name (e.g.MyPipelineEvent→ hookmyPipeline). Exceptions from external event handlers are always swallowed.
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
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.
<?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; }
}$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.
public function myNewHook(MyNewHookEvent $event): void
{
$data = $event->getData();
$data['extra'] = 'added by plugin';
$event->setData($data);
}| 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) |
-
Event → caller: Ctrl+click
@see - Caller → handlers: Find Usages on Event class
- Handler → caller: Find Usages on Event in parameter type-hint