| 1 | +# Worked example — a WordPress plugin under SAMA v2 |
| 2 | + |
| 3 | +This is the same shape as [/sama/v2/example-crud](/sama/v2/example-crud) but in PHP, against WordPress idioms — `add_action`/`add_filter` hooks, `$wpdb`, REST routes via `register_rest_route`, shortcodes, custom post types. The four canonical layers and the import law are unchanged; only the **profile** sublayer split changes to reflect what WordPress code actually does. |
| 4 | + |
| 5 | +The example domain is an **event-registration plugin** (`events-plugin`): visitors register for events, pay via Stripe, get a confirmation email, admins manage attendees from `/wp-admin`. |
| 6 | + |
| 7 | +> **Note on the verifier:** the live verifier at [/sama/v2/verify](/sama/v2/verify) currently scans TypeScript only (regex on `from "./...ts"` imports). The SAMA v2 spec is language-agnostic — the import law applies equally to PHP `use` statements and PSR-4 autoloaded classes — but a PHP-aware verifier is a separate piece of work. This page documents the **architectural shape**; the mechanical conformance check for a PHP repo would need its own implementation against the same §4 rules. |
| 8 | + |
| 9 | +## The profile |
| 10 | + |
| 11 | +```toml |
| 12 | +sama_version = "2.0" |
| 13 | +profile = "wordpress-plugin" |
| 14 | + |
| 15 | +# Layer 0 — Pure. Domain types, pricing math, validation rules. No WP |
| 16 | +# globals ($wpdb, $post, $_POST), no calls to WP functions, no I/O, |
| 17 | +# no clock. PHP itself only. |
| 18 | +[layers.0] |
| 19 | +prefixes = ["a_"] |
| 20 | + |
| 21 | +# Layer 1 — Core. Business decisions and orchestration. May not touch |
| 22 | +# WP globals or call WP functions. Takes adapters at construction |
| 23 | +# through the interfaces declared in a_ports.php. |
| 24 | +# - b1_ policy: pure decisions (canRegister, isEligibleForRefund) |
| 25 | +# - b2_ service: orchestrators that compose policy + delegate |
| 26 | +[layers.1] |
| 27 | +sublayers = [ |
| 28 | + { name = "policy", prefix = "b1_" }, |
| 29 | + { name = "service", prefix = "b2_" }, |
| 30 | +] |
| 31 | + |
| 32 | +# Layer 2 — Adapter. The WordPress boundary. THIS is where $wpdb, |
| 33 | +# wp_mail, get_option, $_POST, REST args, and HTTP-to-Stripe live. |
| 34 | +# - c1_ repository: $wpdb queries (one per custom post type / table) |
| 35 | +# - c2_ gateway: outbound HTTP (Stripe) + wp_mail() wrappers |
| 36 | +# - c3_ controller: REST/admin/shortcode INPUT parsing — turns |
| 37 | +# $_POST, $_GET, json_decode(WP_REST_Request body), and admin |
| 38 | +# form posts into typed values |
| 39 | +[layers.2] |
| 40 | +sublayers = [ |
| 41 | + { name = "repository", prefix = "c1_" }, |
| 42 | + { name = "gateway", prefix = "c2_" }, |
| 43 | + { name = "controller", prefix = "c3_" }, |
| 44 | +] |
| 45 | + |
| 46 | +# Layer 3 — Entry. WordPress hook registrations + REST route |
| 47 | +# registrations + shortcode callbacks + admin pages. Owns no business |
| 48 | +# logic; every callback is parse-via-c3 → invoke-b2 → serialize. |
| 49 | +[layers.3] |
| 50 | +prefixes = ["d_"] |
| 51 | +``` |
| 52 | + |
| 53 | +Lex check: `a_ < b1_ < b2_ < c1_ < c2_ < c3_ < d_`. Layer order: `0 < 1 < 1 < 2 < 2 < 2 < 3`. ✓ |
| 54 | + |
| 55 | +## The directory |
| 56 | + |
| 57 | +``` |
| 58 | +events-plugin/ |
| 59 | +├── events-plugin.php # WP-required root file (4 lines: header |
| 60 | +│ # comment + require __DIR__.'/src/d_main.php') |
| 61 | +├── src/ |
| 62 | +│ ├── a_event.php # Layer 0 — Event, EventStatus types |
| 63 | +│ ├── a_registration.php # Layer 0 — Registration, RegistrationStatus |
| 64 | +│ ├── a_attendee.php # Layer 0 — Attendee, Email types |
| 65 | +│ ├── a_pricing.php # Layer 0 — pure pricing + tax math |
| 66 | +│ ├── a_ports.php # Layer 0 — interfaces: |
| 67 | +│ │ # EventRepository, RegistrationRepository, |
| 68 | +│ │ # PaymentGateway, EmailGateway, Clock |
| 69 | +│ ├── b1_registration_policy.php # Layer 1 — canRegister, isEligibleForRefund |
| 70 | +│ ├── b1_capacity_policy.php # Layer 1 — availableSeats, isWaitlistEligible |
| 71 | +│ ├── b2_registration_service.php # Layer 1 — register, cancel, refund (compose) |
| 72 | +│ ├── b2_payment_service.php # Layer 1 — chargeRegistration, refundRegistration |
| 73 | +│ ├── c1_event_repo.php # Layer 2 — $wpdb-backed EventRepository |
| 74 | +│ ├── c1_registration_repo.php # Layer 2 — $wpdb-backed RegistrationRepository |
| 75 | +│ ├── c2_stripe_gateway.php # Layer 2 — outbound HTTP to Stripe via wp_remote_post |
| 76 | +│ ├── c2_email_gateway.php # Layer 2 — wp_mail() wrapper |
| 77 | +│ ├── c3_rest_controller.php # Layer 2 — parses WP_REST_Request bodies |
| 78 | +│ ├── c3_admin_controller.php # Layer 2 — parses admin form $_POST |
| 79 | +│ ├── c3_shortcode_controller.php # Layer 2 — parses shortcode atts |
| 80 | +│ ├── d_main.php # Layer 3 — boots autoloader, wires deps, fires |
| 81 | +│ │ # all add_action() registrations below |
| 82 | +│ ├── d_rest_routes.php # Layer 3 — register_rest_route() calls |
| 83 | +│ ├── d_admin_pages.php # Layer 3 — add_menu_page() + form handlers |
| 84 | +│ ├── d_shortcodes.php # Layer 3 — add_shortcode() callbacks |
| 85 | +│ └── d_cron_hooks.php # Layer 3 — wp_schedule_event() + cron callbacks |
| 86 | + |
| 87 | ++ sibling *.test.php for every b*_ and c*_ file (Modeled-tests §4.3) |
| 88 | +``` |
| 89 | + |
| 90 | +The root-level `events-plugin.php` is WP's required header file — it has the plugin metadata WordPress scans for, and a single `require` line. Everything else lives under `src/` and follows the prefix scheme. |
| 91 | + |
| 92 | +## Layer 0 — Pure |
| 93 | + |
| 94 | +Domain types as plain PHP classes, validation rules as data, pure transforms. **No WP function calls whatsoever**: no `$wpdb`, no `$_POST`, no `get_option`, no `wp_mail`, no `current_time`, no `$post`. PHP standard library only. |
| 95 | + |
| 96 | +```php |
| 97 | +// a_event.php |
| 98 | +final class Event { |
| 99 | + public function __construct( |
| 100 | + public readonly string $id, |
| 101 | + public readonly string $title, |
| 102 | + public readonly string $startsAtIso, // ISO 8601 string; produced by Layer 1+ |
| 103 | + public readonly int $capacity, |
| 104 | + public readonly int $priceCents, |
| 105 | + ) {} |
| 106 | +} |
| 107 | + |
| 108 | +// a_ports.php — the interfaces Layer 1 calls THROUGH. Concrete |
| 109 | +// implementations live in Layer 2; declaring the interface here lets |
| 110 | +// b2_registration_service.php import only the type, never the c1_ class. |
| 111 | +interface EventRepository { |
| 112 | + public function findById(string $id): ?Event; |
| 113 | + public function save(Event $event): void; |
| 114 | + public function listUpcoming(string $nowIso): array; |
| 115 | +} |
| 116 | + |
| 117 | +interface PaymentGateway { |
| 118 | + public function charge(string $registrationId, int $amountCents): PaymentResult; |
| 119 | +} |
| 120 | + |
| 121 | +interface Clock { |
| 122 | + public function nowIso(): string; |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +The Clock port matters because `time()`, `current_time()`, `new DateTime()` are all forms of I/O the spec keeps out of Layer 0/1. Higher layers inject a `Clock` instance whose Layer 2 implementation calls WP's `current_time('mysql')`. Tests construct a `FixedClock` that returns whatever ISO string the test wants. |
| 127 | + |
| 128 | +## Layer 1 — Core |
| 129 | + |
| 130 | +Business logic. Two sublayers — `b1_` policy (pure functions over domain state) and `b2_` service (orchestrators that compose policy + call through ports). |
| 131 | + |
| 132 | +```php |
| 133 | +// b1_capacity_policy.php — pure |
| 134 | +final class CapacityPolicy { |
| 135 | + public static function availableSeats(Event $event, int $confirmedCount): int { |
| 136 | + return max(0, $event->capacity - $confirmedCount); |
| 137 | + } |
| 138 | + |
| 139 | + public static function isWaitlistEligible(Event $event, int $confirmedCount): bool { |
| 140 | + return $confirmedCount >= $event->capacity |
| 141 | + && $confirmedCount < $event->capacity + 10; // 10-seat waitlist |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +// b2_registration_service.php — orchestrator |
| 146 | +final class RegistrationService { |
| 147 | + public function __construct( |
| 148 | + private EventRepository $events, |
| 149 | + private RegistrationRepository $registrations, |
| 150 | + private PaymentGateway $payments, |
| 151 | + private EmailGateway $emails, |
| 152 | + private Clock $clock, |
| 153 | + ) {} |
| 154 | + |
| 155 | + public function register(RegisterInput $input): Registration { |
| 156 | + $event = $this->events->findById($input->eventId) |
| 157 | + ?? throw new \RuntimeException('event not found'); |
| 158 | + $confirmed = $this->registrations->countConfirmed($event->id); |
| 159 | + if (CapacityPolicy::availableSeats($event, $confirmed) <= 0) { |
| 160 | + throw new \RuntimeException('event sold out'); |
| 161 | + } |
| 162 | + $registration = new Registration(/* ... */); |
| 163 | + $charge = $this->payments->charge($registration->id, $event->priceCents); |
| 164 | + // ... persist + email confirmation ... |
| 165 | + return $registration; |
| 166 | + } |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +Notice what `RegistrationService` does **not** import: any `c1_`/`c2_`/`c3_`/`d_` file. It takes ports at construction. The DI happens in Layer 3. |
| 171 | + |
| 172 | +## Layer 2 — Adapter |
| 173 | + |
| 174 | +The WordPress boundary. Three sublayers: |
| 175 | + |
| 176 | +```php |
| 177 | +// c1_event_repo.php — implements a_ports.EventRepository against $wpdb |
| 178 | +final class WpdbEventRepository implements EventRepository { |
| 179 | + public function __construct(private \wpdb $db) {} |
| 180 | + |
| 181 | + public function findById(string $id): ?Event { |
| 182 | + $row = $this->db->get_row( |
| 183 | + $this->db->prepare("SELECT * FROM {$this->db->prefix}events WHERE id = %s", $id) |
| 184 | + ); |
| 185 | + return $row ? $this->rowToEvent($row) : null; |
| 186 | + } |
| 187 | + |
| 188 | + public function save(Event $event): void { |
| 189 | + $this->db->replace("{$this->db->prefix}events", [ |
| 190 | + 'id' => $event->id, |
| 191 | + 'title' => $event->title, |
| 192 | + 'starts_at' => $event->startsAtIso, |
| 193 | + 'capacity' => $event->capacity, |
| 194 | + 'price_cents' => $event->priceCents, |
| 195 | + ]); |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +// c2_stripe_gateway.php — outbound HTTP via wp_remote_post |
| 200 | +final class StripePaymentGateway implements PaymentGateway { |
| 201 | + public function __construct(private string $apiKey) {} |
| 202 | + |
| 203 | + public function charge(string $registrationId, int $amountCents): PaymentResult { |
| 204 | + $res = wp_remote_post('https://api.stripe.com/v1/charges', [ |
| 205 | + 'headers' => ['Authorization' => "Bearer {$this->apiKey}"], |
| 206 | + 'body' => ['amount' => $amountCents, 'currency' => 'eur', 'metadata[registration_id]' => $registrationId], |
| 207 | + ]); |
| 208 | + // ... parse $res, return PaymentResult ... |
| 209 | + } |
| 210 | +} |
| 211 | + |
| 212 | +// c3_rest_controller.php — parses incoming WP_REST_Request bodies. |
| 213 | +// THIS is the only sublayer allowed to call json_decode on external |
| 214 | +// input, read $_POST, or inspect raw request bodies. |
| 215 | +final class RegistrationRestController { |
| 216 | + public function parseRegisterRequest(\WP_REST_Request $req): RegisterInputOrError { |
| 217 | + $body = $req->get_json_params(); // WP's parser; the boundary is here |
| 218 | + if (!is_array($body)) return RegisterInputOrError::error('invalid JSON'); |
| 219 | + if (empty($body['event_id']) || !is_string($body['event_id'])) { |
| 220 | + return RegisterInputOrError::error('missing event_id'); |
| 221 | + } |
| 222 | + // ... validate every field, return typed RegisterInput ... |
| 223 | + } |
| 224 | +} |
| 225 | +``` |
| 226 | + |
| 227 | +Within Layer 2, controllers may import gateways + repositories (controller is the outermost sublayer in the adapter layer). Repositories may not import controllers — they don't know they're serving an HTTP request. |
| 228 | + |
| 229 | +## Layer 3 — Entry |
| 230 | + |
| 231 | +WordPress hook registrations, REST routes, shortcodes, admin pages. **Owns zero business logic.** Every callback is the same three-step composition: parse via `c3_*` → call `b2_*_service` → return. |
| 232 | + |
| 233 | +```php |
| 234 | +// d_main.php — bootstraps the plugin. Constructs concrete adapters, |
| 235 | +// wires them into services, exposes the services to the hook files. |
| 236 | +global $events_plugin_services; |
| 237 | + |
| 238 | +$events_plugin_services = (object)[ |
| 239 | + 'events' => new WpdbEventRepository($GLOBALS['wpdb']), |
| 240 | + 'registrations' => new WpdbRegistrationRepository($GLOBALS['wpdb']), |
| 241 | + 'payments' => new StripePaymentGateway(get_option('events_plugin_stripe_key')), |
| 242 | + 'emails' => new WpMailEmailGateway(), |
| 243 | + 'clock' => new WpClock(), |
| 244 | +]; |
| 245 | + |
| 246 | +require __DIR__ . '/d_rest_routes.php'; |
| 247 | +require __DIR__ . '/d_admin_pages.php'; |
| 248 | +require __DIR__ . '/d_shortcodes.php'; |
| 249 | +require __DIR__ . '/d_cron_hooks.php'; |
| 250 | + |
| 251 | +// d_rest_routes.php |
| 252 | +add_action('rest_api_init', function () { |
| 253 | + global $events_plugin_services; |
| 254 | + register_rest_route('events-plugin/v1', '/register', [ |
| 255 | + 'methods' => 'POST', |
| 256 | + 'callback' => function (\WP_REST_Request $req) { |
| 257 | + $controller = new RegistrationRestController(); |
| 258 | + $parsed = $controller->parseRegisterRequest($req); // c3 |
| 259 | + if ($parsed->isError()) return new \WP_Error('bad_request', $parsed->error(), ['status' => 400]); |
| 260 | + $service = new RegistrationService( |
| 261 | + $GLOBALS['events_plugin_services']->events, |
| 262 | + $GLOBALS['events_plugin_services']->registrations, |
| 263 | + $GLOBALS['events_plugin_services']->payments, |
| 264 | + $GLOBALS['events_plugin_services']->emails, |
| 265 | + $GLOBALS['events_plugin_services']->clock, |
| 266 | + ); |
| 267 | + $registration = $service->register($parsed->value()); // b2 |
| 268 | + return rest_ensure_response(['id' => $registration->id]); |
| 269 | + }, |
| 270 | + 'permission_callback' => '__return_true', |
| 271 | + ]); |
| 272 | +}); |
| 273 | + |
| 274 | +// d_shortcodes.php |
| 275 | +add_shortcode('event_register_button', function ($atts) { |
| 276 | + global $events_plugin_services; |
| 277 | + $atts = (new RegistrationShortcodeController())->parseAtts($atts); // c3 |
| 278 | + $event = $events_plugin_services->events->findById($atts->eventId); // direct c1 OK (read path) |
| 279 | + return render_register_button($event); |
| 280 | +}); |
| 281 | +``` |
| 282 | + |
| 283 | +Hook registrations live in Layer 3 because they're the *wiring* — they tell WordPress "when X happens, call this callback." The callback body composes c3 + b2; it does not implement business rules inline. |
| 284 | + |
| 285 | +## Common mistakes — and the check that catches them |
| 286 | + |
| 287 | +| What a WordPress dev might do | Which v2 check catches it | |
| 288 | +|---|---| |
| 289 | +| Call `$wpdb->get_results(...)` inside `b2_registration_service.php` | **#6 Law (§1.2)** — Layer 1 → Layer 2 is upward (use the `EventRepository` port instead) | |
| 290 | +| Use `$_POST['event_id']` inside `b1_registration_policy.php` | **#6 Law** + **#4 Modeled-boundary** — superglobals are Layer 2 boundary input, accessible only in `c3_*` controllers | |
| 291 | +| Put `wp_mail(...)` directly inside a `d_*` hook callback (skipping the `c2_email_gateway`) | **Not a v2 violation** — Layer 3 may use Layer 2 directly. But it's a *design smell*: business outcomes (sending a confirmation) belong in `b2_*_service`, which the hook should call. The verifier won't catch this; a code reviewer might. | |
| 292 | +| Register `add_action('init', ...)` inside `b2_registration_service.php` | **#6 Law** — `add_action` is a Layer 2 framework binding (WP function call); Layer 1 must not depend on it. Hooks live in `d_*` only. | |
| 293 | +| Call `current_time('mysql')` in `a_pricing.php` | **#6 Law** + **#7 Consistency** — `a_` claims Pure but the file imports a WP function (Layer 2). Use a `Clock` port instead. | |
| 294 | +| Add a top-level `src/helpers.php` with no prefix | **#2 Architecture** — unprefixed file maps to no canonical layer | |
| 295 | +| Let `c1_registration_repo.php` grow to 800 LOC of SQL | **#5 Atomic** — over the 700-line cap; split (e.g. into `c1_registration_repo.php` + `c1_registration_repo_queries.php`) | |
| 296 | +| `json_decode($req->get_body())` inside a `d_*` REST callback | **#4 Modeled-boundary** — JSON parsing is a Layer 2 controller concern; move it into `c3_rest_controller.php` | |
| 297 | +| Same-layer reverse: `b1_*` policy importing a `b2_*` service | **#6 Law (§2.2)** — `b2` is later in the sublayer order; `b1` may not import `b2` | |
| 298 | + |
| 299 | +## WordPress idioms that fit cleanly under v2 |
| 300 | + |
| 301 | +- **Custom Post Types** — register them in Layer 3 (`d_main.php` calls `register_post_type` during the `init` hook). Their schemas (allowed statuses, taxonomy structure) live in Layer 0 as plain data. |
| 302 | +- **The `$wpdb` global** — only `c1_*` repositories may touch it. Pass `$GLOBALS['wpdb']` into the repository constructor in `d_main.php`; never reach for the global from anywhere else. |
| 303 | +- **Hook priorities & filter chains** — they live in `d_*` only. If your `b2_*_service.php` needs configurable behavior, that's a port (interface in `a_ports.php`) passed at construction, not an `apply_filters()` call from inside business logic. |
| 304 | +- **REST permission callbacks** — small enough to inline in `d_rest_routes.php`. Anything beyond an auth check (e.g. "may this user cancel this registration?") becomes a `b1_*` policy function. |
| 305 | +- **Transients / object cache** — Layer 2 adapter. The cache is I/O, even if it looks like a hash table. |
| 306 | + |
| 307 | +## See also |
| 308 | + |
| 309 | +- [/sama/v2](/sama/v2) — the canonical spec this profile instantiates |
| 310 | +- [/sama/v2/example-crud](/sama/v2/example-crud) — the same shape for a Bun/TypeScript HTTP service |
| 311 | +- [/sama/v2/verify](/sama/v2/verify) — the live TS-only verifier (PHP support is a separate piece of work) |
| 312 | + |
| 313 | +[← /sama/v2](/sama/v2) · [← /sama](/sama) |