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 PHPusestatements 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 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.phpcallsregister_post_typeduring theinithook). Their schemas (allowed statuses, taxonomy structure) live in Layer 0 as plain data. - The
$wpdbglobal — onlyc1_*repositories may touch it. Pass$GLOBALS['wpdb']into the repository constructor ind_main.php; never reach for the global from anywhere else. - Hook priorities & filter chains — they live in
d_*only. If yourb2_*_service.phpneeds configurable behavior, that's a port (interface ina_ports.php) passed at construction, not anapply_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 ab1_*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)