# Worked example — a WordPress plugin under SAMA v2 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. 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`. > **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. ## The profile ```toml sama_version = "2.0" profile = "wordpress-plugin" # Layer 0 — Pure. Domain types, pricing math, validation rules. No WP # globals ($wpdb, $post, $_POST), no calls to WP functions, no I/O, # no clock. PHP itself only. [layers.0] prefixes = ["a_"] # Layer 1 — Core. Business decisions and orchestration. May not touch # WP globals or call WP functions. Takes adapters at construction # through the interfaces declared in a_ports.php. # - b1_ policy: pure decisions (canRegister, isEligibleForRefund) # - b2_ service: orchestrators that compose policy + delegate [layers.1] sublayers = [ { name = "policy", prefix = "b1_" }, { name = "service", prefix = "b2_" }, ] # Layer 2 — Adapter. The WordPress boundary. THIS is where $wpdb, # wp_mail, get_option, $_POST, REST args, and HTTP-to-Stripe live. # - c1_ repository: $wpdb queries (one per custom post type / table) # - c2_ gateway: outbound HTTP (Stripe) + wp_mail() wrappers # - c3_ controller: REST/admin/shortcode INPUT parsing — turns # $_POST, $_GET, json_decode(WP_REST_Request body), and admin # form posts into typed values [layers.2] sublayers = [ { name = "repository", prefix = "c1_" }, { name = "gateway", prefix = "c2_" }, { name = "controller", prefix = "c3_" }, ] # Layer 3 — Entry. WordPress hook registrations + REST route # registrations + shortcode callbacks + admin pages. Owns no business # logic; every callback is parse-via-c3 → invoke-b2 → serialize. [layers.3] prefixes = ["d_"] ``` Lex check: `a_ < b1_ < b2_ < c1_ < c2_ < c3_ < d_`. Layer order: `0 < 1 < 1 < 2 < 2 < 2 < 3`. ✓ ## The directory ``` events-plugin/ ├── events-plugin.php # WP-required root file (4 lines: header │ # comment + require __DIR__.'/src/d_main.php') ├── src/ │ ├── a_event.php # Layer 0 — Event, EventStatus types │ ├── a_registration.php # Layer 0 — Registration, RegistrationStatus │ ├── a_attendee.php # Layer 0 — Attendee, Email types │ ├── a_pricing.php # Layer 0 — pure pricing + tax math │ ├── a_ports.php # Layer 0 — interfaces: │ │ # EventRepository, RegistrationRepository, │ │ # PaymentGateway, EmailGateway, Clock │ ├── b1_registration_policy.php # Layer 1 — canRegister, isEligibleForRefund │ ├── b1_capacity_policy.php # Layer 1 — availableSeats, isWaitlistEligible │ ├── b2_registration_service.php # Layer 1 — register, cancel, refund (compose) │ ├── b2_payment_service.php # Layer 1 — chargeRegistration, refundRegistration │ ├── c1_event_repo.php # Layer 2 — $wpdb-backed EventRepository │ ├── c1_registration_repo.php # Layer 2 — $wpdb-backed RegistrationRepository │ ├── c2_stripe_gateway.php # Layer 2 — outbound HTTP to Stripe via wp_remote_post │ ├── c2_email_gateway.php # Layer 2 — wp_mail() wrapper │ ├── c3_rest_controller.php # Layer 2 — parses WP_REST_Request bodies │ ├── c3_admin_controller.php # Layer 2 — parses admin form $_POST │ ├── c3_shortcode_controller.php # Layer 2 — parses shortcode atts │ ├── d_main.php # Layer 3 — boots autoloader, wires deps, fires │ │ # all add_action() registrations below │ ├── d_rest_routes.php # Layer 3 — register_rest_route() calls │ ├── d_admin_pages.php # Layer 3 — add_menu_page() + form handlers │ ├── d_shortcodes.php # Layer 3 — add_shortcode() callbacks │ └── d_cron_hooks.php # Layer 3 — wp_schedule_event() + cron callbacks + sibling *.test.php for every b*_ and c*_ file (Modeled-tests §4.3) ``` 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. ## Layer 0 — Pure 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. ```php // a_event.php final class Event { public function __construct( public readonly string $id, public readonly string $title, public readonly string $startsAtIso, // ISO 8601 string; produced by Layer 1+ public readonly int $capacity, public readonly int $priceCents, ) {} } // a_ports.php — the interfaces Layer 1 calls THROUGH. Concrete // implementations live in Layer 2; declaring the interface here lets // b2_registration_service.php import only the type, never the c1_ class. interface EventRepository { public function findById(string $id): ?Event; public function save(Event $event): void; public function listUpcoming(string $nowIso): array; } interface PaymentGateway { public function charge(string $registrationId, int $amountCents): PaymentResult; } interface Clock { public function nowIso(): string; } ``` 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. ## Layer 1 — Core Business logic. Two sublayers — `b1_` policy (pure functions over domain state) and `b2_` service (orchestrators that compose policy + call through ports). ```php // b1_capacity_policy.php — pure final class CapacityPolicy { public static function availableSeats(Event $event, int $confirmedCount): int { return max(0, $event->capacity - $confirmedCount); } public static function isWaitlistEligible(Event $event, int $confirmedCount): bool { return $confirmedCount >= $event->capacity && $confirmedCount < $event->capacity + 10; // 10-seat waitlist } } // b2_registration_service.php — orchestrator final class RegistrationService { public function __construct( private EventRepository $events, private RegistrationRepository $registrations, private PaymentGateway $payments, private EmailGateway $emails, private Clock $clock, ) {} public function register(RegisterInput $input): Registration { $event = $this->events->findById($input->eventId) ?? throw new \RuntimeException('event not found'); $confirmed = $this->registrations->countConfirmed($event->id); if (CapacityPolicy::availableSeats($event, $confirmed) <= 0) { throw new \RuntimeException('event sold out'); } $registration = new Registration(/* ... */); $charge = $this->payments->charge($registration->id, $event->priceCents); // ... persist + email confirmation ... return $registration; } } ``` Notice what `RegistrationService` does **not** import: any `c1_`/`c2_`/`c3_`/`d_` file. It takes ports at construction. The DI happens in Layer 3. ## Layer 2 — Adapter The WordPress boundary. Three sublayers: ```php // c1_event_repo.php — implements a_ports.EventRepository against $wpdb final class WpdbEventRepository implements EventRepository { public function __construct(private \wpdb $db) {} public function findById(string $id): ?Event { $row = $this->db->get_row( $this->db->prepare("SELECT * FROM {$this->db->prefix}events WHERE id = %s", $id) ); return $row ? $this->rowToEvent($row) : null; } public function save(Event $event): void { $this->db->replace("{$this->db->prefix}events", [ 'id' => $event->id, 'title' => $event->title, 'starts_at' => $event->startsAtIso, 'capacity' => $event->capacity, 'price_cents' => $event->priceCents, ]); } } // c2_stripe_gateway.php — outbound HTTP via wp_remote_post final class StripePaymentGateway implements PaymentGateway { public function __construct(private string $apiKey) {} public function charge(string $registrationId, int $amountCents): PaymentResult { $res = wp_remote_post('https://api.stripe.com/v1/charges', [ 'headers' => ['Authorization' => "Bearer {$this->apiKey}"], 'body' => ['amount' => $amountCents, 'currency' => 'eur', 'metadata[registration_id]' => $registrationId], ]); // ... parse $res, return PaymentResult ... } } // c3_rest_controller.php — parses incoming WP_REST_Request bodies. // THIS is the only sublayer allowed to call json_decode on external // input, read $_POST, or inspect raw request bodies. final class RegistrationRestController { public function parseRegisterRequest(\WP_REST_Request $req): RegisterInputOrError { $body = $req->get_json_params(); // WP's parser; the boundary is here if (!is_array($body)) return RegisterInputOrError::error('invalid JSON'); if (empty($body['event_id']) || !is_string($body['event_id'])) { return RegisterInputOrError::error('missing event_id'); } // ... validate every field, return typed RegisterInput ... } } ``` 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. ## Layer 3 — Entry 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. ```php // d_main.php — bootstraps the plugin. Constructs concrete adapters, // wires them into services, exposes the services to the hook files. global $events_plugin_services; $events_plugin_services = (object)[ 'events' => new WpdbEventRepository($GLOBALS['wpdb']), 'registrations' => new WpdbRegistrationRepository($GLOBALS['wpdb']), 'payments' => new StripePaymentGateway(get_option('events_plugin_stripe_key')), 'emails' => new WpMailEmailGateway(), 'clock' => new WpClock(), ]; require __DIR__ . '/d_rest_routes.php'; require __DIR__ . '/d_admin_pages.php'; require __DIR__ . '/d_shortcodes.php'; require __DIR__ . '/d_cron_hooks.php'; // d_rest_routes.php add_action('rest_api_init', function () { global $events_plugin_services; register_rest_route('events-plugin/v1', '/register', [ 'methods' => 'POST', 'callback' => function (\WP_REST_Request $req) { $controller = new RegistrationRestController(); $parsed = $controller->parseRegisterRequest($req); // c3 if ($parsed->isError()) return new \WP_Error('bad_request', $parsed->error(), ['status' => 400]); $service = new RegistrationService( $GLOBALS['events_plugin_services']->events, $GLOBALS['events_plugin_services']->registrations, $GLOBALS['events_plugin_services']->payments, $GLOBALS['events_plugin_services']->emails, $GLOBALS['events_plugin_services']->clock, ); $registration = $service->register($parsed->value()); // b2 return rest_ensure_response(['id' => $registration->id]); }, 'permission_callback' => '__return_true', ]); }); // d_shortcodes.php add_shortcode('event_register_button', function ($atts) { global $events_plugin_services; $atts = (new RegistrationShortcodeController())->parseAtts($atts); // c3 $event = $events_plugin_services->events->findById($atts->eventId); // direct c1 OK (read path) return render_register_button($event); }); ``` 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. ## Common mistakes — and the check that catches them | What a WordPress dev might do | Which v2 check catches it | |---|---| | Call `$wpdb->get_results(...)` inside `b2_registration_service.php` | **#6 Law (§1.2)** — Layer 1 → Layer 2 is upward (use the `EventRepository` port instead) | | 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 | | 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. | | 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. | | 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. | | Add a top-level `src/helpers.php` with no prefix | **#2 Architecture** — unprefixed file maps to no canonical layer | | 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`) | | `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` | | 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` | ## WordPress idioms that fit cleanly under v2 - **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. - **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. - **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. - **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. - **Transients / object cache** — Layer 2 adapter. The cache is I/O, even if it looks like a hash table. ## See also - [/sama/v2](/sama/v2) — the canonical spec this profile instantiates - [/sama/v2/example-crud](/sama/v2/example-crud) — the same shape for a Bun/TypeScript HTTP service - [/sama/v2/verify](/sama/v2/verify) — the live TS-only verifier (PHP support is a separate piece of work) [← /sama/v2](/sama/v2) · [← /sama](/sama)