# The Open Graph plugin, rebuilt under SAMA v2 — a thought experiment [Yesterday's audit](/blog/2026-05/sama-v2-wordpress-plugin-audit) walked through what *Open Graph and Twitter Card Tags* (slug `wonderm00ns-simple-facebook-open-graph-tags`, 200k+ installs) looks like through the v2 lens: three god-classes totaling 3,012 lines, 43 raw superglobal accesses, `$wpdb` scattered across layers, zero tests, 0 of 7 §4 checks passing. The post argued — and I still believe — that this score isn't a failure; it's the expected baseline for a plugin built under standard WordPress idioms with no external layer discipline. Bas asked the follow-up the audit didn't answer: **what would the same plugin look like if it had been built under v2 from day one?** This post is a parallel-architecture sketch. Same scope. Same features. Same user-facing behavior. Same plugin metadata. Just laid out so that a PHP-aware v2 verifier would report **7/7 ✓** instead of 0/7. The point isn't to argue the original maintainers *should* refactor — they shouldn't, on the basis of one blog post. The point is to make "100% v2 compliant" concrete enough that readers can map it onto their own code. ## What stays exactly the same Before the architecture, the contract that doesn't move: - The plugin still emits `og:*`, `twitter:*`, and Schema.org JSON-LD on the public side. - Admin UI still has tabs for General / Facebook / Twitter / Schema / SEO / 3rd-party / Tools. - All current settings keys (`fb_image_use_specific`, `fb_twitter_card_type`, etc.) stay valid; existing installs upgrade without losing their config. - WooCommerce product handling, Yoast/AIOSEO compatibility, Facebook locale lookup, FB cache-clear on save — all still there. - The plugin header in `wonderm00n-open-graph.php` is unchanged; WordPress still recognizes it. What changes is *where each piece of work lives in the source tree*, not what the user gets. ## The profile ```toml sama_version = "2.0" profile = "og-plugin" [layers.0] prefixes = ["a_"] [layers.1] sublayers = [ { name = "policy", prefix = "b1_" }, { name = "service", prefix = "b2_" }, ] [layers.2] sublayers = [ { name = "repository", prefix = "c1_" }, # WP options + post meta + transients { name = "gateway", prefix = "c2_" }, # outbound HTTP (Facebook API, locale XML, image probe) { name = "controller", prefix = "c3_" }, # parses admin form posts + extracts PageContext from WP request ] [layers.3] prefixes = ["d_"] ``` Same shape as the [generic wordpress-plugin profile](/sama/v2/example-wordpress) on this site — confirming that one profile actually does generalize to this domain. ## The directory ``` wonderm00ns-simple-facebook-open-graph-tags/ ├── wonderm00n-open-graph.php # 5 lines: WP header + require __DIR__.'/src/d_main.php' ├── src/ │ ├── a_meta_tag.php # MetaTag value type (name|property + content + emitter) │ ├── a_page_context.php # PageContext: title, desc, image, locale, type, url, dates │ ├── a_settings.php # typed Settings (replaces the current mixed-array option) │ ├── a_image_descriptor.php # ImageDescriptor: url + width + height + mime │ ├── a_locale.php # Locale value type (WP code + FB code mapping) │ ├── a_post_target.php # PostTarget: post_id, post_type, is_home, is_archive │ ├── a_ports.php # interfaces: SettingsRepo, PostMetaRepo, ImageCacheRepo, │ │ # FacebookGateway, LocaleXmlGateway, │ │ # ImageProbeGateway, Clock │ │ │ ├── b1_image_selection_policy.php # which image: specific > featured > content > media > default │ ├── b1_description_policy.php # char limit + fallback chain (excerpt > content > default) │ ├── b1_locale_policy.php # WP locale → FB locale code │ ├── b1_tag_visibility_policy.php # settings-driven: which tags to emit on which page types │ ├── b1_url_policy.php # canonical URL + trailing-slash handling │ │ │ ├── b2_og_tag_service.php # build OpenGraphTags from (PageContext, Settings) │ ├── b2_twitter_tag_service.php # build TwitterCardTags │ ├── b2_schema_tag_service.php # build SchemaOrgTags JSON-LD │ ├── b2_woocommerce_service.php # WC product → PageContext enrichment │ ├── b2_settings_service.php # read/write settings with validation │ ├── b2_facebook_cache_service.php # clear FB cache after post save (orchestrates the gateway call) │ │ │ ├── c1_settings_repo.php # wraps get_option/update_option for 'wonderm00n_open_graph_settings' │ ├── c1_post_meta_repo.php # wraps get/update_post_meta for per-post overrides │ ├── c1_image_cache_repo.php # wraps transients for image-size cache │ │ │ ├── c2_facebook_gateway.php # FB Graph debug API call via wp_remote_post │ ├── c2_locale_xml_gateway.php # bundled FacebookLocales.xml + optional refresh from facebook.com │ ├── c2_image_probe_gateway.php # wp_remote_get HEAD to determine image dimensions │ │ │ ├── c3_admin_form_controller.php # parses settings save $_POST into a typed Settings update │ ├── c3_page_context_controller.php # extracts PageContext from the current WP request │ │ # (get_queried_object, $post, is_singular, ...) │ │ │ ├── d_main.php # boots autoloader, constructs adapters, wires deps │ ├── d_frontend_hooks.php # wp_head: emit OG/Twitter/Schema tags │ ├── d_admin_hooks.php # admin_menu, admin_init, settings page registration │ ├── d_admin_pages_view.php # the admin tab markup (was 2,067 LOC across 11 files) │ ├── d_post_save_hooks.php # save_post → b2_facebook_cache_service.clearForUrl() │ ├── d_woocommerce_hooks.php # WC-specific filters │ └── d_third_party_hooks.php # Yoast/AIOSEO/etc. compatibility filters │ ├── lang/ # unchanged: .mo/.po translation files ├── assets/ # unchanged: admin CSS + JS bundles └── tests/ # NEW: PHPUnit, one *.test.php per b*_ and c*_ file ├── b1_image_selection_policy.test.php ├── b2_og_tag_service.test.php ├── ... (one per source file in layers 1 and 2) ``` Approximately **31 source files in `src/`** (vs the original 17 non-vendor files), plus ~20 sibling test files. Roughly the same total LOC as today (6,000-7,000), redistributed: no file over the 700-LOC cap, most files in the 100-300 range. ## Layer 0 — what stops being implicit The original plugin carries its domain types as untyped arrays. The settings option is a mixed array of ints and strings keyed by `fb_*` prefixes; what an emitted tag looks like is buried in string concatenations inside the public class. Under v2, those become explicit types: ```php // a_meta_tag.php final class MetaTag { public function __construct( public readonly string $kind, // 'property' | 'name' | 'itemprop' public readonly string $key, // 'og:title', 'twitter:card', ... public readonly string $content, ) {} } // a_page_context.php — everything the tag services need to know about // the page being rendered, in one immutable struct final class PageContext { public function __construct( public readonly PostTarget $target, public readonly string $title, public readonly string $description, public readonly string $url, public readonly ?ImageDescriptor $image, public readonly Locale $locale, public readonly string $type, public readonly ?string $publishedAtIso, public readonly ?string $modifiedAtIso, public readonly ?string $authorName, public readonly array $sections, ) {} } // a_ports.php — interfaces Layer 1 calls through interface SettingsRepository { public function load(): Settings; public function save(Settings $settings): void; } interface FacebookGateway { public function clearCacheForUrl(string $url): FacebookResult; } interface ImageProbeGateway { public function probe(string $imageUrl): ?ImageDescriptor; } interface Clock { public function nowIso(): string; } ``` Compare to the original: `class-webdados-fb-open-graph.php` lines 141-195 declare the default options as a 60-key associative array with implicit typing (`'fb_image_use_specific' => 1` could mean a boolean, could mean a count, the reader has to infer). Under v2, `Settings` is a class with typed properties; the verifier has something to check imports against. No file in Layer 0 touches WordPress. No `$wpdb`, no `get_option`, no `current_time` — the `Clock` port exists exactly so Layer 0/1 stay pure. ## Layer 1 — what the policy/service split buys The original `public/class-webdados-fb-open-graph-public.php` (1,554 lines) does seven things at once: image URL resolution, OG-tag emission, Twitter-Card emission, Schema.org JSON-LD emission, WooCommerce product handling, third-party SEO compatibility (Yoast, AIOSEO), and WP filter callbacks that wire it all up. Reading that file requires holding all seven contexts in mind simultaneously. Under v2 they split into eleven files, each ~150-300 lines, each with a sibling test: ```php // b1_image_selection_policy.php — pure decision, no I/O final class ImageSelectionPolicy { public static function pick(PageContext $ctx, Settings $settings, array $candidates): ?ImageDescriptor { // candidates is the result of a query to the database/post-meta — // selection logic only operates on the already-fetched list if ($settings->useSpecific && ($candidates['specific'] ?? null) !== null) return $candidates['specific']; if ($settings->useFeatured && ($candidates['featured'] ?? null) !== null) return $candidates['featured']; if ($settings->useContent && ($candidates['content'] ?? null) !== null) return $candidates['content']; if ($settings->useMedia && ($candidates['media'] ?? null) !== null) return $candidates['media']; if ($settings->useDefault && ($candidates['default'] ?? null) !== null) return $candidates['default']; return null; } } // b2_og_tag_service.php — orchestrator. Takes ports at construction. final class OgTagService { public function __construct( private SettingsRepository $settings, private PostMetaRepository $postMeta, private ImageProbeGateway $imageProbe, private TagVisibilityPolicy $visibility, ) {} public function buildTagsFor(PageContext $ctx): array { $settings = $this->settings->load(); if (!$this->visibility->shouldEmitOg($ctx, $settings)) return []; $tags = []; if ($settings->titleShow) $tags[] = new MetaTag('property', 'og:title', $ctx->title); if ($settings->urlShow) $tags[] = new MetaTag('property', 'og:url', $ctx->url); if ($settings->descShow) $tags[] = new MetaTag('property', 'og:description', $ctx->description); if ($settings->imageShow && $ctx->image) $tags[] = new MetaTag('property', 'og:image', $ctx->image->url); if ($settings->localeShow) $tags[] = new MetaTag('property', 'og:locale', $ctx->locale->fbCode); // ... ~25 lines, each line one well-named conditional return $tags; } } ``` The `OgTagService` knows nothing about HTTP, the database, `$wpdb`, or WordPress at all. It takes a `PageContext` and `Settings` (both Layer 0 values), returns an array of `MetaTag`s. It's testable with one fixture and one assertion. Today's public class can't be tested without booting WordPress. ## Layer 2 — every boundary in exactly one place The audit counted **43 raw `$_POST` / `$_GET` / `$_REQUEST` accesses**. Under v2 there's exactly **one** file that touches superglobals: `c3_admin_form_controller.php`. Everywhere else, code receives typed values. ```php // c3_page_context_controller.php — turns the current WP request into PageContext. // THIS is the only file that calls get_queried_object, $post, is_singular, etc. final class PageContextController { public function __construct( private PostMetaRepository $postMeta, private LocalePolicy $localePolicy, private ImageProbeGateway $imageProbe, private Clock $clock, ) {} public function extract(): PageContext { global $post; $queried = get_queried_object(); $target = new PostTarget( postId: $post->ID ?? 0, postType: get_post_type() ?: '', isHome: is_front_page(), isArchive: is_archive(), ); // ... build PageContext field by field, with all the WP boundary calls // (get_the_title, get_permalink, get_the_excerpt, $post->post_modified, ...) // happening here ONLY } } // c1_settings_repo.php final class WpOptionSettingsRepository implements SettingsRepository { public function load(): Settings { $raw = get_option('wonderm00n_open_graph_settings', []); // map raw array to typed Settings, validating + applying defaults } public function save(Settings $settings): void { update_option('wonderm00n_open_graph_settings', $settings->toArray()); } } // c2_facebook_gateway.php — outbound HTTP, isolated final class FacebookGraphGateway implements FacebookGateway { public function __construct(private string $appId, private string $appSecret) {} public function clearCacheForUrl(string $url): FacebookResult { $response = wp_remote_post( "https://graph.facebook.com/?id={$url}&scrape=true&access_token={$this->appId}|{$this->appSecret}" ); if (is_wp_error($response)) return FacebookResult::error($response->get_error_message()); $body = json_decode(wp_remote_retrieve_body($response), true); return is_array($body) ? FacebookResult::ok($body) : FacebookResult::error('non-JSON body'); } } ``` The Modeled-boundary check (§4.4) is satisfied by construction: `JSON.parse`, `new URL`, raw `$_POST`, `wp_remote_*`, `$wpdb` — every one of those tokens grep would find appears only inside a `c[123]_*.php` file. Any other layer doing the same thing is a verifier violation. ## Layer 3 — wiring only Hook registrations live in `d_*.php`. Every callback is the same shape: parse → service → return. ```php // d_frontend_hooks.php add_action('wp_head', function () { global $og_plugin_deps; $ctx = $og_plugin_deps->pageContext->extract(); $ogTags = $og_plugin_deps->ogService->buildTagsFor($ctx); $twTags = $og_plugin_deps->twService->buildTagsFor($ctx); $schemaTags = $og_plugin_deps->schemaService->buildTagsFor($ctx); echo render_meta_tags($ogTags); // pure string concat from MetaTag[] echo render_meta_tags($twTags); echo render_schema_jsonld($schemaTags); }, 5); // d_post_save_hooks.php add_action('save_post', function (int $postId) { if (wp_is_post_revision($postId)) return; global $og_plugin_deps; $url = get_permalink($postId); $og_plugin_deps->fbCacheService->clearForUrl($url); // b2 service, not gateway directly }, 10, 1); ``` Notice the `d_post_save_hooks.php` callback: it talks to a Layer 1 *service* (`b2_facebook_cache_service`), not directly to the Layer 2 gateway. That's not strictly required by the verifier (`d_` files may import from any lower layer), but it's the kind of thing the §5 violation count "trailing signal" surfaces. A direct `c2_*` call from a hook is a sign that the operation has no business rules yet — the moment one shows up ("don't notify FB if the post is a draft"), the call site has to move into a service anyway. ## What the §5 metrics would look like Estimated for the refactored plugin, alongside what the original would report: | metric | original plugin | v2-refactored plugin | tdd.md (measured) | |---|---|---|---| | §4 checks passing | 0 / 7 | **7 / 7** | 7 / 7 | | graphDepth | ~3 | ~5 (entry → service → policy → port → adapter) | 7 | | boundaryRatio | <10% | **100%** | 100% | | workingSetFit (50–500 LOC) | ~47% | **~95%** | 80% | | violationCounts (sum) | 17+ | **0** | 0 | The `workingSetFit` rises from ~47% to ~95% because the 1,554-line god-class becomes eleven 150-300 line files, the 784-line admin class becomes six files, and the 11 admin UI files consolidate into one view file plus a controller. Almost everything lands in the sweet spot. ## What this isn't This is a thought experiment, not a pull request. - The original plugin's maintainers know their codebase and their users; they have ten years of incidents and edge cases in their heads that this sketch ignores. A real refactor would take months and risk breaking compatibility with the PRO add-on, with WooCommerce, with Yoast. - The §5 deltas in the table above are *predictions*, not measurements. The plugin would have to be actually refactored, the PHP-aware verifier built, and the metrics computed for the numbers to be empirical. Today they're informed guesses. - "100% v2 compliant" doesn't mean "better." It means "every architectural decision is *visible* in the file tree, every boundary is in one place, every business rule has a unit test that fails for the right reason." Whether that's worth ten months of work for a 200k-install plugin that already does its job is the maintainer's call, not mine. What the sketch buys is the same thing the [§5 metrics emitter](/blog/2026-05/sama-v2-metrics-emitter) bought: now there's a concrete description of "the same plugin under v2." If the maintainers ever do this refactor — or if someone forks the plugin to build v2-discipline-from-scratch — the deltas are measurable on the axes both versions share. That's the kind of comparison §6 of the spec actually requires before claiming v2 is worth following. --- **Companion posts:** - [The audit](/blog/2026-05/sama-v2-wordpress-plugin-audit) — pointing v2 at the real plugin: 0 of 7 checks pass, why that's the expected baseline - [The generic WP profile](/sama/v2/example-wordpress) — a hypothetical event-registration plugin under v2 from scratch - [The §5 metrics emitter](/blog/2026-05/sama-v2-metrics-emitter) — the empirical artefact §6 requires before any later claim can be measured as a delta - [The v2 verifier post](/blog/2026-05/sama-v2-verifier-and-the-rename) — yesterday's piece on building the verifier itself