Worked example — a WordPress plugin under SAMA v2

This is the same shape as /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 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

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.

// 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).

// 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:

// 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.

// 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 Lawadd_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 Consistencya_ 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 — the canonical spec this profile instantiates
  • /sama/v2/example-crud — the same shape for a Bun/TypeScript HTTP service
  • /sama/v2/verify — the live TS-only verifier (PHP support is a separate piece of work)

← /sama/v2 · ← /sama