helpers
2 days ago
routes
3 days ago
src
3 days ago
CLAUDE.md
1 week ago
config.php
1 week ago
helpers.php
6 months ago
hooks.php
4 months ago
CLAUDE.md
163 lines
| 1 | # SureCart PHP Backend Patterns |
| 2 | |
| 3 | Patterns and conventions for working in the `app/` directory. See root `CLAUDE.md` for architecture overview. |
| 4 | |
| 5 | ## Adding a New Model |
| 6 | |
| 7 | ```php |
| 8 | // app/src/Models/MyModel.php |
| 9 | namespace SureCart\Models; |
| 10 | |
| 11 | class MyModel extends Model { |
| 12 | protected $endpoint = 'my_models'; // -> https://api.surecart.com/v1/my_models |
| 13 | protected $object_name = 'my_model'; // request body wrapper key + hook namespace |
| 14 | } |
| 15 | ``` |
| 16 | |
| 17 | The `/v1` segment comes from `RequestService::getBaseUrl()` (`app/src/Request/RequestService.php:131-133`), not from `$endpoint`. |
| 18 | |
| 19 | Three base classes, not two: |
| 20 | |
| 21 | - `Model` — API-backed (96 models). |
| 22 | - `DatabaseModel` — WP custom table. **Also requires `protected $table_name`** (e.g. `Integration.php:15`); its inherited `$endpoint` is vestigial, since queries go through `Query::init($this->object_name)->from($this->table_name)` (`DatabaseModel.php:169`). |
| 23 | - `ExternalApiModel` — third-party API. Different contract: `protected $base_url` plus `$endpoint`, its own `makeRequest()` with transient caching, never touches `RequestService` or `SURECART_API_URL`. One subclass repo-wide (`IntegrationCatalog.php`). |
| 24 | |
| 25 | Traits live in `SureCart\Models\Traits` and must be imported by FQCN — `use SureCart\Models\Traits\HasDates;`. |
| 26 | |
| 27 | 48 traits exist. The ones most used across real models: `HasDates`, `HasCustomer`, `HasPrice`, `HasCheckout`, `HasProduct`, `HasSubscription`, `HasPurchases`, `HasLineItem`, `HasAffiliation`, `CanFilter`, `HasBillingAddress`, `HasShippingAddress`, `HasPaymentIntent`, `HasPaymentMethod`, `HasImageSizes`, `CanDuplicate`, `HasOrder`, `HasCharge`, `HasLicense`, `CanResendNotifications`, `SyncsCustomer` (the Customer↔WP_User sync), `HasAttributes` (fillable/guarded machinery). |
| 28 | |
| 29 | Note the singular/plural pairs are different traits: `HasSubscription` (4 uses) vs `HasSubscriptions` (1), `HasPurchase` (2) vs `HasPurchases` (3). Check which one you actually want. Rarely used but real: `HasDiscount`, `CanFinalize`, `HasProcessorType`, `HasShippingChoices`, `HasCommissionStructure`. `HasServiceFee` exists but is dead. |
| 30 | |
| 31 | ## Adding a New REST Endpoint |
| 32 | |
| 33 | Three-file pattern, then register in `app/config.php`: |
| 34 | |
| 35 | ```php |
| 36 | // 1. Model (see above) |
| 37 | |
| 38 | // 2. app/src/Controllers/Rest/MyModelController.php |
| 39 | class MyModelController extends RestController { |
| 40 | protected $class = MyModel::class; |
| 41 | } |
| 42 | |
| 43 | // 3. app/src/Rest/MyModelRestServiceProvider.php |
| 44 | class MyModelRestServiceProvider extends RestServiceProvider { |
| 45 | protected $endpoint = 'my_models'; // /wp-json/surecart/v1/my_models |
| 46 | protected $controller = MyModelController::class; |
| 47 | protected $methods = ['index', 'create', 'find', 'edit', 'delete']; |
| 48 | } |
| 49 | |
| 50 | // 4. Add MyModelRestServiceProvider::class to 'providers' array in app/config.php |
| 51 | ``` |
| 52 | |
| 53 | `$methods` supports exactly these five — there is no `batch` or `archive`. **Extra endpoints do not go in `$methods`**; override `registerRoutes()` and call `register_rest_route()` directly (44 providers do this, e.g. `ProductsRestServiceProvider.php:41-54` for `products/{id}/sync/`). |
| 54 | |
| 55 | Most real providers omit `$methods` entirely and inherit the default. |
| 56 | |
| 57 | Permission callbacks are plain `current_user_can()`, but with **one capability verb per callback**, not a single capability for the endpoint: |
| 58 | |
| 59 | ```php |
| 60 | get_item_permissions_check -> read_sc_coupons |
| 61 | get_items_permissions_check -> read_sc_coupons |
| 62 | create_item_permissions_check -> publish_sc_coupons |
| 63 | update_item_permissions_check -> edit_sc_coupons |
| 64 | delete_item_permissions_check -> delete_sc_coupons |
| 65 | ``` |
| 66 | |
| 67 | The capability is not always derived from the endpoint name — `manage_sc_shop_settings` (39 uses) and `manage_options` (22) are the two most common strings overall, used by settings and protocol providers. |
| 68 | |
| 69 | **The base class defaults every permission check to `false`** (`RestServiceProvider.php:349-399`), so a provider that omits them registers a fully locked endpoint rather than an open one. |
| 70 | |
| 71 | ## Error Handling |
| 72 | |
| 73 | ```php |
| 74 | // Controllers: propagate WP_Error upward — REST layer auto-converts to response |
| 75 | $result = MyModel::create($data); |
| 76 | if (is_wp_error($result)) { |
| 77 | return $result; |
| 78 | } |
| 79 | |
| 80 | ``` |
| 81 | |
| 82 | The conversion to an HTTP response is WordPress core's (`WP_REST_Server::error_to_response()`), not SureCart's — the route callback just returns the `WP_Error` untouched (`RestServiceProvider.php:245-249`). It carries a correct status because `ErrorsTranslationService::translate()` sets one (`:213-220`). |
| 83 | |
| 84 | **A throwing integration does take down the rest of the chain.** Integration handlers are invoked through `IntegrationService::callMethod()` (`app/src/Integrations/IntegrationService.php:339-367`), which has **no** try-catch. Only one of the five `surecart/purchase_created` dispatch sites wraps the `do_action` (`DraftCheckoutsController.php:92-97`); the other four do not (`DraftCheckoutsController.php:43`, `CheckoutsController.php:159,243`, `AsyncWebhookService.php:102`). Do not assume you are isolated. |
| 85 | |
| 86 | `ErrorsTranslationService` translates API error codes to user messages automatically — every non-200/201 API response is funnelled through it (`app/src/Request/RequestService.php:365-371`), nothing opt-in. Lookup files are in `Support/Errors/Translations/` (`codes.php`, `attributes.php`, `types.php`, `types-replaceable.php`). Do not duplicate translation logic; extend via the `surecart/translated_error` / `surecart/translated_errors` filters. |
| 87 | |
| 88 | ## Adding a New Integration |
| 89 | |
| 90 | **Purchase-sync** integrations extend `IntegrationService` and implement `PurchaseSyncInterface` — 7 of the 24 directories under `app/src/Integrations/` (LifterLMS, LearnDash, LearnDashGroup, TutorLMS, MemberPress, BuddyBoss, User). The rest are not this shape: AffiliateWP extends `\Affiliate_WP_Base`, Yoast extends `NoIndexService`, and the page-builder/SEO integrations are plain service classes. |
| 91 | |
| 92 | ```php |
| 93 | // Contract from IntegrationInterface — EIGHT methods, not six: |
| 94 | getName(), getModel(), getLogo(), getLabel(), getItemLabel(), getItemHelp(), |
| 95 | getItems($items = [], $search = ''), getItem($id) |
| 96 | |
| 97 | // Required from PurchaseSyncInterface: |
| 98 | onPurchaseCreated($integration, $wp_user) |
| 99 | onPurchaseInvoked($integration, $wp_user) |
| 100 | onPurchaseRevoked($integration, $wp_user) |
| 101 | ``` |
| 102 | |
| 103 | **These are not abstract methods.** `IntegrationService` declares zero abstract methods — the word `abstract` appears once in the file, on the class declaration (`IntegrationService.php:13`). All eight are concrete stubs returning `''` (`:33-104`), satisfying `IntegrationInterface` on the subclass's behalf. So PHP will **not** error if you omit one: the integration silently registers under an empty name/model, and omitting `getItems()`/`getItem()` renders an empty item picker in the admin UI. That is a very different failure mode from a fatal. |
| 104 | |
| 105 | The dispatcher passes a **third** argument the interface does not declare — `$this->$method($integration, $purchase->getWPUser(), $purchase)` (`IntegrationService.php:365`). Every shipped implementation declares only two, so `$purchase` is available but invisible. |
| 106 | |
| 107 | Implementing `PurchaseSyncInterface` also auto-subscribes you to a **fourth** event: `surecart/purchase_updated` → `onPurchaseUpdated(Purchase $purchase, $request)` (`IntegrationService.php:143`, method at `:179-248`), which fans out to `onPurchaseProductAdded`, `onPurchaseProductRemoved`, `onPurchaseQuantityUpdated` and `onPurchaseProductUpdated`. This is the plan-change path — an integration that ignores it will not revoke access on a product swap. |
| 108 | |
| 109 | - Integration data (product/price/variant -> third-party item mapping) stored in `surecart_integrations` table via `Integration` model (DatabaseModel) |
| 110 | - Register service provider in `app/config.php` under `'providers'` |
| 111 | - Actions fire from **five** places, not two: `DraftCheckoutsController::finalize()` (`:94`) and `::manuallyPay()` (`:43`), `CheckoutsController::finalize()` (`:243`) and `::manuallyPay()` (`:159`), and the webhook processor (`AsyncWebhookService.php:102`). Do not hook them manually — and when tracing "where does this fire", do not stop at the draft controller |
| 112 | |
| 113 | ## Routes and Middleware |
| 114 | |
| 115 | The dominant pattern in `app/routes/admin.php` is a `->group()` with `->setNamespace()`, and the inner routes discriminated by the `action` URL var — not a flat chain: |
| 116 | |
| 117 | ```php |
| 118 | \SureCart::route() |
| 119 | ->where( 'admin', 'sc-products' ) |
| 120 | ->middleware( 'user.can:edit_sc_products' ) |
| 121 | ->middleware( 'assets.components' ) // loads Stencil component assets |
| 122 | ->middleware( 'assets.admin_colors' ) |
| 123 | ->setNamespace( '\\SureCart\\Controllers\\Admin\\Products\\' ) |
| 124 | ->group( function () { |
| 125 | \SureCart::route()->get()->where( 'sc_url_var', false, 'action' )->handle( 'ProductsController@index' ); |
| 126 | \SureCart::route()->get()->where( 'sc_url_var', 'edit', 'action' )->handle( 'ProductsController@edit' ); |
| 127 | \SureCart::route()->get()->where( 'sc_url_var', 'toggle_archive', 'action' ) |
| 128 | ->middleware( 'archive_model:product' )->handle( 'ProductsController@toggleArchive' ); |
| 129 | } ); |
| 130 | ``` |
| 131 | |
| 132 | `->setNamespace()` is what makes the bare `'ProductsController@index'` string resolve. Without it that handler will not resolve. |
| 133 | |
| 134 | App-level middleware aliases — this is the complete registry (`app/config.php:384-390`): |
| 135 | `archive_model`, `edit_model`, `nonce`, `webhooks`, `assets.components`, `assets.brand_colors`, `assets.admin_colors`. Note `edit_model` is registered but used by **zero** routes. |
| 136 | |
| 137 | Six more come from the framework (`core/core/src/Kernels/KernelsServiceProvider.php:31-36`): `flash`, `old_input`, `csrf`, `user.logged_in`, `user.logged_out`, and `user.can` — the most-used middleware in the routes. The argument after the colon is a capability (`user.can:edit_sc_products`), parsed in `core/core/src/Middleware/UserCanMiddleware.php:41-49`. |
| 138 | |
| 139 | Much of `app/routes/web.php` applies middleware by FQCN rather than alias, so the alias table is only part of the picture. |
| 140 | |
| 141 | ## Webhook Event -> WordPress Action Mapping |
| 142 | |
| 143 | | API Event | WordPress Action | |
| 144 | |---|---| |
| 145 | | `purchase.created` | `surecart/purchase_created` | |
| 146 | | `purchase.revoked` | `surecart/purchase_revoked` | |
| 147 | | `purchase.invoked` | `surecart/purchase_invoked` | |
| 148 | | `purchase.updated` | `surecart/purchase_updated` | |
| 149 | | `customer.updated` | `surecart/customer_updated` | |
| 150 | | `account.updated` | `surecart/account_updated` | |
| 151 | | `subscription.renewed` | `surecart/subscription_renewed` | |
| 152 | |
| 153 | Price/product CRUD events also fire but are rarely hooked externally. |
| 154 | |
| 155 | The mapping is **derived, not a lookup table** — `AsyncWebhookService::createEventName()` (`:117-120`) is `str_replace('.', '_', $type)` prefixed with `surecart/`. So any event follows the same rule and the table above is convenience, not the contract. |
| 156 | |
| 157 | Two gates decide whether a documented event actually fires: |
| 158 | |
| 159 | 1. **Subscription allowlist** — only events listed in `app/config.php:451-466` (`'webhook_events'`) are subscribed. This, not the derivation, is the authority on what arrives. |
| 160 | 2. **Object-model gate** — `AsyncWebhookService::handle()` bails before `do_action` if the payload's `object` has no entry in its `$models` map (`:93-99`). An unmapped object is marked processed and **dropped silently**. |
| 161 | |
| 162 | Handlers receive two arguments, `($model, $webhook->data)` (`:102`) — which is why `onPurchaseUpdated()` takes a `$request` second parameter. |
| 163 |