Beyond hooks and filters.
Making WordPress plugins behave like one application through shared policies, explicit state transitions and integrations designed to survive failure.
A directory plugin knows about listings. A membership plugin knows about members. A payment system knows about transactions. Your website needs them to agree about what a person can actually do.
It is tempting to connect those systems with a growing collection of hooks. One callback assigns a role. Another publishes a listing. A third hides a button. The integration appears complete until a renewal arrives twice, a cancellation arrives late, or an API endpoint applies a different rule from the template.
The difficult part of plugin customisation is not finding a hook. It is giving the application a coherent model of state, authority and failure.
Write the policy before the callbacks
Start with business capabilities: publish a teacher profile, receive an enquiry, appear in a particular search tier, edit an organisation’s details. Define the conditions for each capability in one place. A role name or product ID may contribute to the decision, but neither is the whole policy.
For example, publishing a profile may require ownership, a valid entitlement, completed moderation and an account that is not suspended. A single is_premium flag cannot explain which condition failed. Return a structured decision with a stable reason code so the interface, support tools and audit log can explain the outcome consistently.
Our AMTR case study describes the practical need to connect directory and membership behaviour. The interfaces and event-handling pattern below are an architectural example, not a published extract of that client’s private application code.
Put adapters at the edges
Wrap the parts of each plugin that the application relies on: fetching entitlement state, resolving a listing owner or applying a subscription transition. Give the rest of your code an application interface. A template should ask whether a person may publish, not reproduce a membership plugin’s internal metadata lookup.
Prefer documented APIs and extension points. When a necessary integration depends on an undocumented behaviour, isolate it in one adapter, record the supported version range and add a focused compatibility test. Avoid editing vendor files; an update should not erase the integration.
The dependency direction matters. Your policy should consume normalised facts from adapters. It should not depend on which hook happened to call it or whether the request came from the front end.
Authorise the action where it happens
Hiding a button is useful interface design, but it does not protect the operation. Apply the same policy to the REST endpoint, form handler and background command. Authentication establishes identity; a nonce addresses a different concern; neither alone proves that this person may change this resource.
This illustrative REST route separates boundary permission checks from the application command. The command must recheck the policy against current state and make the transition atomically; the permission callback cannot eliminate the race between checking and writing.
add_action('rest_api_init', function () {
register_rest_route('enjin/v1', '/profiles/(?P<id>\d+)/publish', [
'methods' => 'POST',
'args' => [
'id' => [
'required' => true,
'validate_callback' => static function ($value) {
return ctype_digit((string) $value) && (int) $value > 0;
},
'sanitize_callback' => 'absint',
],
],
'permission_callback' => static function (WP_REST_Request $request) {
$allowed = enjin_policy()->may_publish_profile(
get_current_user_id(), (int) $request['id']
);
return $allowed ? true : new WP_Error(
'enjin_forbidden', 'You cannot publish this profile.',
['status' => rest_authorization_required_code()]
);
},
'callback' => static function (WP_REST_Request $request) {
return enjin_profiles()->publish(
(int) $request['id'], get_current_user_id()
);
},
]);
});The application interfaces in this snippet are intentionally not implemented. The policy must reject unauthenticated callers, check ownership or an explicit administrative capability, and evaluate the current entitlement and moderation state. The command should return a bounded, public response or a WP_Error, never an unfiltered internal record. See WordPress’s custom endpoint guidance for the permission callback contract.
Assume an event can arrive twice
These failure cases are part of real provider contracts. Stripe’s webhook documentation, for example, describes duplicate deliveries and does not guarantee event ordering. Verify signatures against the raw request body, validate the event’s account context, and acknowledge only after the work has been durably accepted. A successful HTTP response followed by an in-memory task that disappears on process exit is not reliable ingestion.
Payment events and background jobs are commonly retried. A callback that extends a subscription by thirty days every time it runs can grant sixty days when a delivery repeats. Prefer applying an authoritative period end and event version over incrementing a value blindly.
Persist a receipt identified by the provider and its event ID, backed by a unique database constraint. Scope that identifier to the relevant provider account or tenant when IDs are not globally unique. A “does this event exist?” query followed by an insert is not a concurrency guarantee; two workers can pass the check together.
When the receipt and state transition are in your own transactional tables on the same database connection, commit them together. Marking the receipt complete before the transition risks losing work after a crash. Changing state first and recording completion later risks applying it twice. Do not assume arbitrary WordPress hooks, remote calls or vendor tables participate safely in that transaction.
Out-of-order events need a state model
A unique event ID prevents a duplicate from applying twice. It does not stop an older, distinct event from overwriting newer state. Use the provider’s sequence or authoritative resource version where available. A timestamp alone may not establish ordering when events share a timestamp or delivery clocks differ.
If the provider offers no usable ordering guarantee, an event can trigger a fresh read of the authoritative resource rather than act as the final truth itself. Coalesce those reads, respect rate limits and define how conflicting states are resolved. Keep enough event metadata to diagnose the transition without storing unnecessary personal information.
Make transitions explicit: pending, active, grace period, expired, suspended. Decide which events may move between them. A suspended account should not silently become active because an unrelated payment notification arrived.
Separate committing state from delivering side effects
Suppose the entitlement update succeeds but the process dies before it schedules a welcome email. Retrying the event may now correctly do nothing, leaving the email unsent. Scheduling first creates the opposite risk: an email describing a state that never committed.
An outbox records the intended side effect in the same transaction as the application state. A worker delivers pending entries and records the outcome. Delivery still needs an idempotency strategy at the destination; an outbox does not magically make email or external APIs exactly-once. AWS’s transactional outbox guidance describes the dual-write problem and the need to handle duplicate delivery.
Where an external system cannot deduplicate, document the residual failure window and choose a sensible recovery policy. Do not hold a database transaction open while waiting for a remote service. Keep retries bounded, back off after failures and surface exhausted jobs to an operator.
Make reconciliation part of the implementation
Events can be missed, credentials can expire and manual changes can happen outside your application. A scheduled reconciliation process should compare the authoritative provider state with the local projection and repair safe discrepancies. Record what changed and why.
Reconciliation is also a useful migration tool. Backfill existing members, compare policy decisions against expected outcomes, and only then enable the new integration. Keep reversible deployment steps and a way to replay failed work without repeating irreversible side effects.
Test the seams, not only the happy path
Build a small contract suite around plugin boundaries. Include repeated and reordered events, expired credentials, an account suspended during a queued job, a profile owned by another user and a process failure between state changes. Run those tests against the plugin versions you intend to deploy.
Expose useful operational facts: the last successful reconciliation, pending outbox age, failed adapter calls and the reason for a denied capability. A collection of plugins becomes maintainable software when its rules and failure states can be understood without reverse-engineering a web of callbacks.
Code examples illustrate the architecture and identify application-specific interfaces. Adapt them to your environment and test the failure paths before deployment. Sources are linked alongside the relevant discussion.