Docs  /  Developer Guide  /  Architecture

Architecture

How this plugin is wired end-to-end. Read this first if you’re going to write an addon or contribute.

What an “update” actually is

If you only remember one thing from this page: an order can have many updates, and each update is one conversation about that order. A delivery delay, a refund question, a follow-up to a previous issue — each gets its own update.

An update has:

  • One assignee — the team member responsible.
  • A status — the colour-coded label (Neutral, Notice, Warning, Resolved, plus whatever you’ve added).
  • A customer-visible flag — whether the customer can see this update or not.
  • Two parallel note streams — internal (team-only) and customer (visible to the customer’s portal).
  • Optional attachments on any note.
  • Up to one rating (1–5 stars + optional comment) once it’s solved.
  • A complete tracking log of every meaningful event — created, assigned, status changed, solved, reopened, rated.

This shape lives across five custom tables. The plugin never modifies WordPress core or WooCommerce tables — uninstall it and your orders are untouched.

Where the code lives

Everything is under src/. PSR-4 namespaced. Six modules:

src/
  API/         REST endpoints and the routing trait
  Admin/       Order meta box, settings, analytics, admin bar, staff-facing emails
  Frontend/    Customer portal, customer-facing emails, shortcodes
  Helpers/     Stateless utilities (HposHelper, View, DateHelper, etc.)
  Shared/      DB layer, attachments, config, validation, notifications
  Welcome/     First-activation welcome page

No top-level templates/ or includes/ folders. If you’re adding code and not sure where it goes, look at the nearest sibling and copy the pattern.

The plugin boots from order-updates-for-woo.php into OrderUpdatesForWoo\Plugin::boot(). That method is the master list of every class that runs at runtime — if you ever wonder “wait, what wires this up?”, that’s the file.

How a request flows

Take a concrete example. A staff member writes a customer note and hits send. Here’s the path:

  1. The admin JS sends a POST to /wp-json/order-updates-for-woo/v1/updates/123/customer-notes.
  2. WP REST routes it to AddCustomerNoteEndpoint::handle().
  3. The endpoint’s can_access() verifies the nonce and the user’s capability on the order. If either fails, you get a 403.
  4. Otherwise handle() sanitises the note, calls OrderUpdatesDb::create_customer_note(), and queues an Action Scheduler job to email the customer.
  5. The plugin fires order_updates_for_woo_after_add_customer_note — this is where any addon you’ve hooked steps in.
  6. Endpoint returns the saved note JSON. Admin JS appends it to the DOM and the user sees their message land.
  7. A few seconds later, Action Scheduler runs the email job. The plugin’s CustomerOrderUpdateEmail (a WC_Email subclass) builds the body and calls wp_mail().

That’s the shape of every mutation: REST endpoint → DB layer → sync UI response → async notification. No magic, no surprise side effects.

The DB layer

One class to know: OrderUpdatesDb in src/Shared/Updates/. It owns four custom tables (updates, internal notes, customer notes, ratings) and a separate AttachmentsDb handles the fifth (attachments). Every read on this class is wrapped in wp_cache_get / wp_cache_set under Constants::CACHE_GROUP. Every write invalidates the relevant cache keys.

Two rules: never read or write these tables from outside OrderUpdatesDb, and every query that takes user input uses $wpdb->prepare(). The plugin doesn’t interpolate user-controlled values into SQL anywhere — if you find a place that does, that’s a bug, please open an issue.

The REST API surface

27 endpoints under /wp-json/order-updates-for-woo/v1/. Same shape across all of them: each endpoint class implements Registrable and uses the VerifiesAccess trait. Every can_access() checks the WP REST nonce first; staff endpoints then check capabilities, customer endpoints check the order key.

The admin meta box, the customer portal, and the 30-second poll all talk to this REST API. There’s no separate admin-AJAX or custom JSON endpoint elsewhere — if you want to know what the plugin can do over HTTP, the REST API page has the full inventory.

Emails go through Action Scheduler

The plugin never calls wp_mail() from inside a REST handler. Every notification is queued as an Action Scheduler job (via the helper AsyncJob::queue()) and runs a few seconds later. This keeps the admin’s “Save” click from waiting on SMTP — the page returns immediately, the email arrives shortly after.

Every email is a WC_Email subclass extending OrderUpdateEmailBase. Nine email classes total. Subject and body are customisable through the standard WooCommerce email customiser; deeper changes (markup, layout) go through theme overrides. See Email customisation for the full picture.

Realtime is polling (for v1.0)

The customer portal polls /customer-thread/poll every 30 seconds. The admin meta box uses WP Heartbeat for the admin-bar badge. No WebSockets in the core plugin — that’s a deliberate scope decision for v1.0.

The poll endpoint is cache-friendly: responses are keyed by order_id + since_note_id and cached for 15 seconds in a transient. Two customers watching the same order share one DB query.

If you need true realtime, hook order_updates_for_woo_realtime_config and inject your driver (Pusher, Ably, your own WebSocket service). The customer JS will use your driver and fall back to polling automatically if your connection drops. See the Hooks page for the worked example.

HPOS and classic storage

The plugin declares HPOS compatibility and works on both storage modes. The rule: every order touch goes through WooCommerce’s APIwc_get_order(), $order->get_meta(), $order->update_meta_data(), $order->save(). Never get_post_meta() on an order ID, never WP_Query against shop_order.

For admin URLs that link to the orders list, use HposHelper::orders_list_url() — it returns the right URL for both storage modes. Hardcoding edit.php?post_type=shop_order 404s on HPOS-only stores.

How wiring works

There’s no DI container. Plugin::boot() is a long, flat list of new ClassName( $dep1, $dep2 ) calls. That’s the trade-off: less abstraction means easier to read for someone new, harder to wire complex object graphs.

For an addon, do the same thing — bootstrap in a single boot() method on a top-level class, wire your own dependencies inline. Don’t introduce a container just for your addon; it’ll feel out of place against the rest of the codebase.