This document details the architecture of the plugin-core webhook system. It is designed to handle high-concurrency environments, out-of-order delivery, and distributed race conditions inherent in payment gateway integrations.
The fundamental problem with webhook invocations is that they are asynchronous and unreliable. A shop might receive a FULFILL invocation before an AUTHORIZED invocation due to network latency or retry logic.
plugin-core solves this using a State Machine "Catch-Up" loop:
- Fetch Remote State: It gets the authoritative state from the PostFinanceCheckout API (e.g.,
FULFILL). - Fetch Local State: It looks up the last processed state locally (e.g.,
CREATE). - Calculate Path: It determines the transition path:
[PENDING -> AUTHORIZED -> FULFILL]. - Execute Loop: It executes the commands for every step in that path, in order.
Why? This guarantees that the shop's data always reaches the correct final state and triggers all necessary side effects (emails, invoices), even if intermediate invocations were lost or arrived out of order.
Reference: For a visual representation of the transaction states, see the official PostFinanceCheckout Transaction Process documentation.
When high-volume transactions occur, multiple invocations (e.g., Authorized, Invoice, Fulfill) can arrive at the exact same millisecond. Without locking, this leads to data corruption, duplicate orders, or database deadlocks.
To prevent this, plugin-core orchestrates a strict locking lifecycle via the WebhookLifecycleHandler.
- Target: The specific webhook entity (e.g.,
Transaction_123). - Purpose: Prevents two processes from handling the exact same event at once.
- Behavior: If Process A is handling
Transaction_123, Process B waits. When Process B finally runs, it checks the database, seesTransaction_123is already up-to-date, and skips.
- Target: The shop resource being modified (e.g.,
Order_999). - Purpose: Prevents different types of webhook invocations (e.g.,
Transactionvs.DeliveryIndication) from modifying the same Order simultaneously. - Behavior: Even if they are different entities, if they update the same Order, they must wait in line.
Implementation Example: See example/src/MyExampleLifecycleHandler.php for a reference implementation of
getLockableResourcesused to define these locks.
The WebhookProcessor enforces an Atomic Transaction per step in the catch-up loop.
- Pre-Process: Acquires locks, re-checks the state (to skip duplicates), and starts a database transaction.
- Execute: Runs the business logic Command.
- Post-Process: Updates the
webhook_progresstable, commits the transaction, and releases locks.
Critical Rule: The webhook_progress table is the source of truth for plugin-core. It must be updated in the same atomic transaction as the business logic.
Locking ensures commands run one at a time, but it does not guarantee the order in which they run. A Delivery Indication (forcing Manual Review) might arrive milliseconds after a Capture command (forcing Processing).
To prevent valid data from being overwritten, Commands must implement State Precedence.
A Command must never blindly overwrite the status of a resource. It must check the current state of the resource (loaded freshly from the database) and verify that the new state is "compatible" with the existing one.
The Priority Hierarchy (Example):
- Final States (Closed, Canceled) - Highest Priority (Immutable)
- Review States (Manual Check Required) - High Priority
- Process States (Processing, Authorized) - Standard Priority
The Rule:
A Standard Priority command (like Authorized) must not overwrite a High Priority state (like Manual Check).
-
Scenario: If an Order is in
Manual Check, and theAuthorizedcommand runs:- Incorrect: Overwrite status to
Processing. (The merchant loses the review flag). - Correct: Record the authorization transaction, but skip the status update.
- Incorrect: Overwrite status to
-
Implementation Example: See example/src/Transaction/TransactionListener.php for a reference implementation of this safety check.
To implement plugin-core in a new shop system:
- Database: Create a
webhook_progresstable with columns(entity_id, entity_type, last_processed_state). - Lifecycle Handler: Create a base handler extending
DefaultWebhookLifecycleHandler.- Implement
getLastProcessedState. - Implement
getLockableResources(returning both Entity and Resource lock IDs). - Implement
doAcquireLock/doReleaseLock. - Implement
preProcess(Start Transaction) /postProcess(Commit & Update Table).
- Implement
- Commands: Write simple commands.
- Use traits for data loading.
- Use the Safe Update Pattern (Fresh Load + State Precedence) to prevent overwriting critical statuses.
- Wiring: Configure the
WebhookProcessorwith your handler.