Architecture
{/* AUTO-GENERATED from ../docs/architecture.md by scripts/sync-dev-docs.mjs — do not edit by hand. */}
Start here: if this is your first pass at the codebase, read
agency-dev-overview.mdfirst — it’s a 30-minute orientation layered on top of this deep-dive, with the pitfalls and extension-point pointers agencies need.
Last verified against source: 2026-04-21 (branch claude/cool-williams-92e9be).
Audience: intermediate-to-senior PHP/JS developers who haven’t read this plugin
yet — agency partners extending it, and integrators making non-trivial changes.
If you only need to list hooks, see hooks.md. For merchant-facing
guidance, see merchant-guide.md. For the day-one agency
orientation (quick-start, extension patterns, pitfalls), see
agency-dev-overview.md.
1. High-level component map
Section titled “1. High-level component map”graph TB subgraph Browser["Browser — wp-admin"] React["Admin React SPA<br/>src/ build to assets/admin.js"] end
subgraph WP["WordPress runtime"] Bootstrap["dino-discounts.php<br/>bootstrap"] Plugin["Plugin<br/>DI container and wiring"] Migrations["Migrations<br/>version-gated upgrades"] Seeder["StarterData<br/>StarterRuleSeeder — onboarding"]
subgraph API["REST API — namespace dino-discounts/v1"] RestAPI["RestAPI"] Rules["RulesController"] Settings["SettingsController"] Snapshots["SnapshotsController"] Preview["PreviewController"] Perf["PerformanceController"] Analytics["AnalyticsController"] Taxonomy["TaxonomyController"] CouponCodes["CouponCodesController"] Onboarding["OnboardingController"] CartScen["CartScenariosController"] ProdSrch["ProductSearchController"] end
subgraph Engine["Rule engine — stateless per request"] RE["RulesEngine"] RM["RuleMatcher"] TE["TargetingEvaluator"] UT["UsageTracker — DB-backed"] RS["RuleSanitizer"] Strat["Strategy/*<br/>Tiered, Bulk, XForY, MixMatch"] AT["AnalyticsTracker<br/>background-writes orders"] end
subgraph Cart["Storefront integration"] CC["CartCoupons<br/>virtual coupons + AppliesDiscount trait"] CCI["CouponCodeInterceptor<br/>priority 9, before virtual factory"] CCM["CouponCodeManager<br/>persistent code pool"] SAE["StoreApiExtension<br/>WC Blocks, read-only surface"] SF["Storefront/*<br/>display, spend-more nudge, filter"] end
subgraph Diag["Diagnostics and ops"] PM["PerformanceMonitor"] CD["ConflictDetector"] Log["Observability Logger"] Snap["History RuleSnapshots"] end end
subgraph OptDB["wp_options (shared table)"] OptRules[("dino_discounts_active_rules")] OptSettings[("dino_discounts_global_settings")] OptVersion[("dino_discounts_version")] OptScenarios[("dino_discounts_cart_scenarios")] OptFirstPub[("dino_discounts_first_publish_ts")] end
subgraph CustomDB["Custom tables (prefix wp_dino_)"] TblUsageCounts[("wp_dino_usage_counts")] TblUsageStats[("wp_dino_usage_stats")] TblSnapshots[("wp_dino_rule_snapshots")] TblAnalytics[("wp_dino_analytics_events")] TblCoupons[("wp_dino_coupon_codes")] TblGenLog[("wp_dino_coupon_generation_log")] end
React -- "fetch /wp-json/dino-discounts/v1/*" --> RestAPI Bootstrap --> Plugin Plugin --> API Plugin --> Engine Plugin --> Cart Plugin --> Diag Plugin --> Migrations Plugin --> Seeder
Rules --> RS Rules --> Snap Rules -- "update_option" --> OptRules Settings -- "update_option" --> OptSettings Onboarding --> Seeder Seeder -- "update_option" --> OptRules
RE -- "get_option" --> OptRules RE --> RM RE --> TE RE --> UT RE --> Strat
UT -- "SELECT/UPDATE" --> TblUsageCounts UT -- "INSERT/UPDATE" --> TblUsageStats
Snap -- "INSERT/UPDATE" --> TblSnapshots Snap -- "restore writes" --> OptRules
AT -- "background INSERT" --> TblAnalytics
CCI --> CCM CCM -- "SELECT/UPDATE" --> TblCoupons CCM -- "INSERT" --> TblGenLog CouponCodes --> CCM CCI --> CC CC --> RE SAE --> CC SF --> RE
Migrations -- "get/update_option" --> OptVersion Migrations -- "CREATE TABLE IF NOT EXISTS" --> CustomDBEvery controller above extends
AbstractController(includes/API/AbstractController.php) for its sharedcheck_permission()callback; it is omitted from the graph to keep the router intent legible.RestAPI.phpis the registrar;VirtualCart.php,VirtualProduct.php, andCartScenarioDefaults.phpare data-shaping helpers rather than route owners.
| Layer | Purpose | Entry points |
|---|---|---|
| Bootstrap | Plugin loader, constants, autoload | dino-discounts.php, includes/Plugin.php |
| Admin React SPA | All rule authoring UI, settings, analytics, history | src/index.js, src/App.js, mounts to #dino-discounts-admin-app (rendered by includes/Admin.php) |
| REST API | Single source of truth for admin writes and previews | includes/API/RestAPI.php registers routes on rest_api_init under dino-discounts/v1 |
| Rule engine | Per-request evaluation of rules → discount results | includes/Engine/RulesEngine.php |
| Storefront | Cart-level application of discounts; Blocks + classic | includes/Cart/CartCoupons.php is the engine entry point. includes/Cart/StoreApiExtension.php is read-only — it surfaces CartCoupons::get_pending_discounts() into the WC Store API response for Blocks checkout, but does NOT invoke RulesEngine itself (includes/Cart/StoreApiExtension.php:152). includes/Storefront/* covers templates and the spend-more nudge. |
| Persistent coupon codes | Real (non-virtual) codes a shopper can type at checkout | includes/Cart/CouponCodeManager.php (code pool CRUD) + includes/Cart/CouponCodeInterceptor.php (priority-9 hook before virtual factory). Backed by wp_dino_coupon_codes and wp_dino_coupon_generation_log. See §3.5. |
| Analytics (write path) | Order-level discount events written in the background | includes/Engine/AnalyticsTracker.php — hooks woocommerce_checkout_order_processed, enqueues a single background action, writes to wp_dino_analytics_events. |
| Analytics (read path) | Exposure to admin via REST | includes/API/AnalyticsController.php — aggregates from wp_dino_analytics_events for the admin dashboard. |
| Diagnostics | Performance monitor, conflict/caching warnings, structured logs | includes/Diagnostics/PerformanceMonitor.php, includes/ConflictDetector.php, includes/Observability/Logger.php |
| History | Rule snapshots (undo / audit) | includes/History/RuleSnapshots.php — persists to wp_dino_rule_snapshots (NOT to wp_options). Snapshot restore writes the chosen snapshot back into dino_discounts_active_rules. |
| Starter data | Onboarding seeds sample rules on first publish | includes/StarterData/StarterRuleSeeder.php — writes dino_discounts_active_rules on the onboarding flow (4th write path to that option). |
| Migrations | Idempotent, version-gated data upgrades + custom-table creation | includes/Migrations.php — runs on admin_init. Also owns create_custom_tables() which issues CREATE TABLE IF NOT EXISTS for all six wp_dino_* tables. |
2. Data flow: admin save → storefront display
Section titled “2. Data flow: admin save → storefront display”sequenceDiagram autonumber participant U as Merchant participant R as React SPA participant API as RulesController participant Options as wp_options participant Shopper participant WC as WC_Cart participant CC as CartCoupons participant RE as RulesEngine
U->>R: Edit rule, click Save R->>API: POST /dino-discounts/v1/rules API->>API: RuleSanitizer sanitize_rules API->>Options: update_option dino_discounts_active_rules API->>API: do_action dino_discounts_rules_saved API-->>R: 200 and sanitized payload
Shopper->>WC: Add or change cart item WC-->>CC: woocommerce_before_calculate_totals CC->>CC: fingerprint cart, check memo alt cache hit CC->>WC: reuse prior virtual coupons else cache miss CC->>RE: evaluate_cart with cart, total, currency, country RE->>Options: get_option dino_discounts_active_rules (object-cached) RE->>RE: apply Readers (kill-switch, stacking mode) loop each active rule RE->>RE: RuleMatcher matches_rule_criteria RE->>RE: TargetingEvaluator evaluate_node (tree walk) alt matches RE->>RE: Strategy calculate, produces DiscountResult end end RE-->>CC: array of DiscountResult CC->>CC: mint virtual coupon code dino_dd_RULEID CC->>WC: apply_coupon dino_dd_RULEID end WC-->>Shopper: Render totals with discount lineKey properties:
- Write paths to
dino_discounts_active_rules, in order of frequency.- Admin UI —
RulesController::save_rules()(includes/API/RulesController.php:225), which delegates toRuleSanitizerand then callsupdate_option('dino_discounts_active_rules', …)at line 270. This is the only path the React app uses. - Snapshot restore —
RuleSnapshots::restore_snapshot()atincludes/History/RuleSnapshots.php:231writes the chosen snapshot back into the rules option (for the “undo” flow). The snapshots themselves live inwp_dino_rule_snapshots, notwp_options. - Onboarding / starter data —
StarterRuleSeeder::seed()atincludes/StarterData/StarterRuleSeeder.php:99writes the sample-rule set when a merchant accepts the onboarding offer. Runs at most once per site. - Migrations — several steps in
includes/Migrations.php(e.g. lines 628, 865, 1014, 1105) rewrite the option in place on version bumps, normalising legacy targeting aliases. These run once per version step underadmin_init.
- Admin UI —
- Read-path for rules is
get_optiononly. TheRulesEngineconstructor (includes/Engine/RulesEngine.php:154) pulls the rule blob viawp_cache_get('rules', …)first and falls back toget_option('dino_discounts_active_rules', …)on a miss (line 161), then caches into the object cache. The resulting array is held on$this->active_rulesfor the life of the instance. ⚠️ Caveat: the engine does query custom tables during evaluation —UsageTrackerreadswp_dino_usage_countsto enforce total-usage caps (see §3.3), andAnalyticsTrackerwriteswp_dino_analytics_eventson order completion. The “no custom tables” rule applies only to the rule blob itself. - Two-layer discount memoisation in
AppliesDiscount::apply_cart_discount()(includes/Cart/Traits/AppliesDiscount.php:136–163). Both layers key on the same cart fingerprint frombuild_cart_fingerprint()(includes/Cart/Traits/ManagesCartState.php:190). The fingerprint is an MD5 ofcart_hash | subtotal | subtotal_incl_tax | currency | country | applied_real_coupons | u<user_id>. Theapplied_real_couponslist starts fromCartCoupons::partition_applied_coupons()and adds back any Dino code whoseactivation_typeis NOT'virtual'(i.e. persistent / proxy codes DO contribute to the fingerprint; onlydino_dd_*virtual codes are excluded, because they’re re-derived each pass and would cause self-invalidation). User ID0for guests, WP user ID for logged-in users.- Request-scoped:
$last_fingerprint/$last_discountsskip the engine on repeatedwoocommerce_before_calculate_totalspasses within the same PHP request. - Cross-request, 60-second transient:
dino_eval_<fingerprint>survives to subsequent requests, so a shopper refreshing the cart page doesn’t re-run the engine.
- Request-scoped:
- Virtual coupons are the application mechanism. No row ever appears in
wp_posts(shop_coupon) for a Dino discount.
3. Rule engine internals
Section titled “3. Rule engine internals”3.1 Request-scoped object graph
Section titled “3.1 Request-scoped object graph”graph LR CC["CartCoupons<br/>uses AppliesDiscount trait"] --> RE[RulesEngine] RE --> RM[RuleMatcher] RE --> TE[TargetingEvaluator] RE --> UT[UsageTracker] RE --> Strategies RM --> TE RM --> UT Strategies --> AS[AbstractStrategy] AS --> Unified[UnifiedStrategy<br/>dispatches on rule.type]Locations:
includes/Engine/RulesEngine.php— orchestrator. Holds active rules, dispatches toRuleMatcher→Strategy.includes/Engine/RuleMatcher.php— does the per-rule checks (active flag, date window, zone, usage caps, targeting root node).includes/Engine/TargetingEvaluator.php— recursive tree walker over{op, children}/{field, op, values}nodes. Depth and node caps enforced as a DoS guard.includes/Engine/UsageTracker.php— store-wide total usage counters. Custom-table-backed: readswp_dino_usage_counts(O(1) per rule) and writes dimensional aggregates towp_dino_usage_statson order completion. The constructor warms a per-request cache ($usage_counts_cache) so each rule is fetched at most once perevaluate_cart()pass. Per-user limits were removed in v4.1.0 alongside the move fromwp_optionsto custom tables (see class docblock, line 24).includes/Engine/RuleSanitizer.php— canonicalises and strips anything the authoring UI didn’t produce; the ONLY gate between REST input and stored JSON.includes/Engine/Strategy/*—UnifiedStrategydispatches internally onrule['type']into one of four allocator branches (cart_level/group_prorated/set_walk_free_lines/set_walk_prorated). Takes theRulesEngineInterfacein its ctor (AbstractStrategy::__construct) so it can reuse item-matching logic.
3.2 The four “Readers” — engine-level short-circuit gates
Section titled “3.2 The four “Readers” — engine-level short-circuit gates”Terminology: “Readers” in this codebase refers specifically to the four
engine-level short-circuits below, which decide whether any rule gets a
chance to run at all. Per-rule checks (usage caps, date ranges, zones, etc.)
are a separate layer inside RuleMatcher::matches_rule_criteria() and are
listed in §3.2.1.
These four Readers are the load-bearing extension seams for merchants and integrators:
| # | Gate | Where | What it does |
|---|---|---|---|
| 1 | Global kill-switch | RulesEngine::evaluate_cart() around includes/Engine/RulesEngine.php:330, setting key wc_coupon_kill_switch | When wc_coupon_kill_switch === 'disable_dino_when_wc_coupon_applied' AND CartCoupons::partition_applied_coupons() reports any non-Dino coupon in the cart, the engine returns [] and records a kill_switch_fired trace. |
| 2 | Stacking mode | Same method, just below the kill-switch. Reads stacking_mode from global settings; StackingMode::DISABLE_ALL returns [] immediately. | Gives the merchant a “freeze all discounts” escape hatch without deactivating rules one-by-one. |
| 3 | Targeting node wc_coupon_applied (alias coupon_applied) | TargetingEvaluator::evaluate_node() case block at includes/Engine/TargetingEvaluator.php:780–781. The v4.18.0 migration rewrites the legacy coupon_applied alias to the canonical wc_coupon_applied. | Per-rule — the author opts an individual rule out when a real WC coupon is present. Narrower than the global kill-switch. |
| 4 | Targeting node dino_coupon_applied (alias dino_campaign_applied) | Same file, includes/Engine/TargetingEvaluator.php:816–817, dispatching to evaluate_dino_coupon_applied() at line 1099. Aliases also migrated in v4.18.0. | Per-rule conditional on whether another Dino rule already matched earlier in the same request — enables “apply this nudge only when nothing else fired.” |
Note on naming. Earlier internal docs referred to a
RuleMatcher::check_wc_coupon_stackingmethod; that symbol no longer exists. The responsibility moved up a level into theRulesEnginekill-switch (gate #1 above) during the v4.19.0 normalisation. When you see the old name in comments or test fixtures, treat it as a pointer to the kill-switch.
3.2.1 Per-rule gates (not Readers — but also gating)
Section titled “3.2.1 Per-rule gates (not Readers — but also gating)”Once the engine is past the four Readers, each rule is tested by
RuleMatcher::matches_rule_criteria() (includes/Engine/RuleMatcher.php:168).
Any of these failing causes the individual rule to be skipped, and the reason
is recorded on $last_fail_reason (line 72) for diagnostics:
| Gate | Check | Fail reason code |
|---|---|---|
| Total usage limit | UsageTracker::check_usage_limits($rule) at line 172 | usage_limit |
| Scheduled date window | Start/end timestamps, store timezone | scheduling |
| Time-of-day window | Daily repeating HH:MM window in store tz | scheduling |
| Currency allow-list | $rule['currencies'] vs active currency | currency |
| Zone (country / region) | ZoneMatcher::matches() at line 349 | zone |
| User role allow-list | Matched against wp_get_current_user() | user_role |
| Targeting tree | TargetingEvaluator::evaluate_node() | targeting |
These gates run per rule per request; the Readers run once per request.
If you’re debugging “why didn’t my rule apply?”, $last_fail_reason is the
fast path — the Readers would have short-circuited the whole engine, so a rule
that reaches the matcher and is rejected is always one of the per-rule gates.
3.3 Strategies and the AppliesDiscount trait
Section titled “3.3 Strategies and the AppliesDiscount trait”Discount calculation is performed by a single UnifiedStrategy
(includes/Engine/Strategy/UnifiedStrategy.php) extending the
AbstractStrategy base (includes/Engine/Strategy/AbstractStrategy.php).
UnifiedStrategy::evaluate() dispatches on rule['type'] into one of four
allocator branches:
tiered→cart_levelallocator — thresholds on cart total or quantity.bulk→group_proratedallocator — per-product quantity breaks.x_for_y→set_walk_free_linesallocator — “buy X get Y” shapes.mix_match→set_walk_proratedallocator — quantity-based across a set of products.
Discount application to the cart is a trait, not a strategy:
includes/Cart/Traits/AppliesDiscount.php provides apply_cart_discount( $cart )
and is used only by CartCoupons (see includes/Cart/CartCoupons.php:69).
Pulling this into a trait keeps CartCoupons focused on virtual-coupon
bookkeeping while allowing the evaluation loop itself to be unit-tested in
isolation. Other files that mention AppliesDiscount only reference it for
parity (e.g. Storefront/StorefrontFilter.php mirrors its currency/country
resolution order).
3.4 Virtual coupon partitioning
Section titled “3.4 Virtual coupon partitioning”Dino never persists a shop_coupon post. Instead:
- After evaluation,
CartCouponsmints a codedino_dd_<rule_id>(prefix constantCartCoupons::COUPON_PREFIXatincludes/Cart/CartCoupons.php:79). VirtualCouponFactory::virtual_coupon_data()hookswoocommerce_get_shop_coupon_dataand materialises the coupon from the in-memory$pending_discountsmap keyed by code.CartCoupons::is_dino_coupon()(includes/Cart/CartCoupons.php:252) andCartCoupons::partition_applied_coupons()(line 303) split the cart’s applied coupons into{ dino: [...], wc: [...] }. The kill-switch (Reader #1) and stacking logic both consume this partition.
This is what “virtual coupon partitioning” refers to: the engine treats
dino_dd_* codes as engine output, and any other code as external input that
may or may not trigger the kill-switch depending on settings.
3.5 Persistent coupon codes (real, not virtual)
Section titled “3.5 Persistent coupon codes (real, not virtual)”Separate from the dino_dd_* auto-applied virtual coupons, the plugin also
supports real codes a shopper types into the cart — single codes per
rule, bulk single-use code pools, and URL-token codes. This is a distinct
code path that sits in front of the virtual-coupon factory:
| Component | Role | File |
|---|---|---|
CouponCodeManager | Static utility: CRUD for the code pool, bulk generation, CSV export, redemption bookkeeping. Owns the wp_dino_coupon_codes + wp_dino_coupon_generation_log tables. | includes/Cart/CouponCodeManager.php |
CouponCodeInterceptor | Hooks woocommerce_get_shop_coupon_data at priority 9 — one priority before the virtual-coupon factory (priority 10). When a shopper enters a code, it resolves the code against the DB pool, confirms the owning rule is active, and either injects a virtual coupon into the existing CartCoupons pipeline OR falls through silently on a collision with a native WC shop_coupon. | includes/Cart/CouponCodeInterceptor.php |
CouponCodesController | REST surface for the admin UI to manage the pool. | includes/API/CouponCodesController.php |
Key properties:
- Collision guard. If a native
shop_couponpost exists with the same code, the interceptor falls through silently and sets a transient (dino_coupon_collision_*) so the merchant sees a warning on their next admin visit. Dino never overrides an existing WC coupon. - Same application pipeline. Once resolved, a persistent code ultimately
flows through the same
CartCouponsvirtual-coupon machinery as an auto-applied rule. The discount calculation is identical. - No
shop_couponposts are ever created. Even for persistent codes — they live inwp_dino_coupon_codes, notwp_posts.
4. Storage, caching, and invalidation
Section titled “4. Storage, caching, and invalidation”4.1 wp_options rows
Section titled “4.1 wp_options rows”| Key | Purpose | Written by | Read by |
|---|---|---|---|
dino_discounts_active_rules | The rule blob (single JSON-serialised array) | RulesController::save_rules, RuleSnapshots::restore_snapshot, StarterRuleSeeder::seed, Migrations::step_* | RulesEngine constructor (via object-cache first, get_option on miss) |
dino_discounts_global_settings | Plugin settings (kill-switch, stacking mode, zones, etc.) | SettingsController, Migrations::step_* | RulesEngine::evaluate_cart, CartCoupons, numerous readers |
dino_discounts_version | Currently-installed schema version | Migrations::maybe_upgrade step-by-step | Migrations::maybe_upgrade gate |
dino_discounts_cart_scenarios | Preview tool’s saved cart scenarios (one blob with defaults + custom) | CartScenariosController, CartScenarioDefaults | PreviewController, admin Preview tab |
dino_discounts_first_publish_ts | Unix timestamp of the merchant’s first publish (onboarding signal) | RulesController::save_rules (once) | OnboardingController, WelcomePanel |
dino_discounts_debug | Developer flag; toggles debug logging | Manually set | RulesEngine around includes/Engine/RulesEngine.php:942 |
4.2 Custom tables (prefix {wp_prefix}dino_)
Section titled “4.2 Custom tables (prefix {wp_prefix}dino_)”Created idempotently by Migrations::create_custom_tables() via dbDelta
with CREATE TABLE IF NOT EXISTS. All six tables exist even on a fresh
install.
| Table | Purpose | Written by | Read by |
|---|---|---|---|
dino_usage_counts | One row per rule — store-wide total count (hot path) | UsageTracker::increment_usage on order completion | UsageTracker::check_usage_limits, pre-warmed each evaluate_cart |
dino_usage_stats | Dimensional aggregates (rule × currency × country × …) | UsageTracker::record_usage_stats | Admin analytics; not read by the engine |
dino_rule_snapshots | Append-only undo/audit log of rule-state changes | RuleSnapshots::create_snapshot on every rule save | SnapshotsController for the admin History tab; restore_snapshot for undo |
dino_analytics_events | One row per order where a Dino discount applied | AnalyticsTracker::process_order_analytics via background action dino_discounts_track_analytics | AnalyticsController for dashboard aggregates |
dino_coupon_codes | Persistent coupon-code pool (single codes, bulk codes, URL tokens) | CouponCodeManager CRUD, CouponCodeInterceptor on redemption | CouponCodeInterceptor::virtual_coupon_data at priority 9 (see §3.5) |
dino_coupon_generation_log | Audit row per bulk-generation batch | CouponCodeManager::generate_bulk_codes | Admin code-management UI |
4.3 Caching layers
Section titled “4.3 Caching layers”| Layer | Scope | Invalidation trigger |
|---|---|---|
WordPress object cache (wp_cache_*, group dino_discounts_rules) | Per-request; shared across processes if a persistent cache is installed | update_option_dino_discounts_active_rules hook in Plugin::define_admin_hooks() flushes it |
RulesEngine::$active_rules | Per instance (one per request) | N/A — instance dies at end of request |
UsageTracker::$usage_counts_cache | Per evaluate_cart pass | N/A — reset per pass |
CartCoupons::$last_fingerprint / $last_discounts | Per request | Cart contents change → fingerprint changes |
Transient dino_eval_<fingerprint> | Cross-request, 60 seconds | Natural TTL, or dino_discounts_cache_flush action |
Invalidation story for the rule blob. On save, RulesController fires
dino_discounts_rules_saved. Plugin::define_admin_hooks() (around
includes/Plugin.php:54–98) registers a cache-flush closure on
update_option_dino_discounts_active_rules plus several WC hooks
(save_post_product, edited_product_cat, woocommerce_shipping_zone_*, …)
that invalidate the object-cache entry. The per-request static on the engine
is inherently bounded to one request, so storefront evaluations following the
save see the new rules on the next request.
5. Extension points (hooks)
Section titled “5. Extension points (hooks)”Theme and plugin developers extend Dino Discounts via the hooks surface.
The full list is auto-generated — always read it from
hooks.md (regenerate with make docs-hooks).
Where to mount your listeners. Prefer a small companion plugin
(/wp-content/plugins/my-shop-dino-hooks/) that loads on plugins_loaded with
a priority later than Dino Discounts. A child theme’s functions.php works
for trivial filters but is fragile: theme switches silently drop your
customisations. Never add listeners inside the Dino Discounts plugin folder —
they’ll be wiped on update.
The hooks that matter most when embedding into another theme/plugin:
dino_discounts_before_apply_coupons/dino_discounts_after_apply_coupons(actions) — wrap the per-request application phase.dino_discounts_discount_applied(action) — fires after each virtual coupon is applied; use for attribution or analytics forwarding.dino_discounts_rules_saved/dino_discounts_settings_saved(actions) — fire after admin writes land; use to invalidate external caches or warm CDN purges.dino_discounts_coupon_label(filter) — rewrite the label rendered for a virtual coupon.dino_discounts_evaluated_discounts(filter) — last-mile mutation of theDiscountResult[]before application (use sparingly; you will see every request).dino_discounts_strategies(filter) — register additional strategies.dino_discounts_targeting_result(filter) — observe targeting decisions for debugging / overrides.dino_discounts_incompatible_plugins/dino_discounts_caching_plugins(filters) — extend theConflictDetectorlists so your own plugin shows up (or doesn’t).
The hook doc is verified against source via
make docs-hooks-check, which also runs as part ofmake qa. If you ship a PR that adds a hook without regenerating the doc, CI fails.
6. Migration and version-skew strategy
Section titled “6. Migration and version-skew strategy”includes/Migrations.php implements an idempotent, version-gated dispatcher:
maybe_upgrade()runs onadmin_init.- It reads
dino_discounts_version(default1.0.0) and compares against theDINO_DISCOUNTS_VERSIONconstant defined indino-discounts.php. - Each step is a
{ key, after, run }record (seeincludes/Migrations.php:64–115). Steps run in array order; theafterversion is written todino_discounts_versiononly once the step’sruncallback has completed without an error, so an interrupted upgrade resumes cleanly on the next admin load. - Steps that target a version higher than the currently-installed plugin
(shipped but not-yet-released) are skipped — this prevents
stored_version > DINO_DISCOUNTS_VERSIONfrom ever happening. - Migrations primarily rewrite the rule JSON in-place (normalising legacy targeting aliases, canonicalising settings keys). The engine and readers then only need to handle canonical shapes.
Version-skew for extenders: because all storefront reads go through the
RulesEngine constructor’s cache+get_option path, downgrading the plugin
while leaving migrated data in place is safe provided your downgrade target still
understands the canonical schema. In practice, we support one major version
back — older readers fall back to legacy aliases via the targeting tree
switches above.
7. Where to look next
Section titled “7. Where to look next”| I want to … | Start here |
|---|---|
| Add a new hook for my theme | hooks.md (auto-generated list) |
| Call the REST API from an external service | rest-api-reference.md |
| Override a shopper-facing template | template-overrides.md |
| Understand bootstrap ordering | architecture-bootstrap-notes.md |
| Troubleshoot cart/coupon interactions | troubleshooting.md |
| Day-one workflow for agency teams | agency-dev-overview.md (parallel WIP) |
Appendix: Key design patterns
Section titled “Appendix: Key design patterns”| Pattern | Where | Purpose |
|---|---|---|
| Strategy | includes/Engine/Strategy/* | Discount calculation per rule type. |
| Trait composition | includes/Cart/Traits/AppliesDiscount.php | Keeps CartCoupons lean while making the evaluation loop reusable. |
| Dependency injection | CartCoupons(RulesEngineInterface $engine), strategies receive the engine | Unit tests substitute a fake engine; avoids hard coupling. |
| Memoisation | CartCoupons::apply_cart_discount() fingerprint map | One engine pass per distinct cart state per request. |
| Object-cache read | RulesEngine::__construct() (wp_cache_get → get_option) | One DB read per request, often zero when the object cache is warm. |
| Observer | dino_discounts_* hooks | Extensibility without code modification. |
| Virtual proxy | CartCoupons + VirtualCouponFactory | Participate in WC’s coupon system with no DB entries. |
| Guard clause | TargetingEvaluator depth/node limits | Protects against pathological rule trees. |
| Version-gated migration | Migrations::maybe_upgrade() | Idempotent, resume-safe upgrades. |