| 1 | +# The Open Graph plugin, rebuilt under SAMA v2 — a thought experiment |
| 2 | + |
| 3 | +[Yesterday's audit](/blog/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. |
| 4 | + |
| 5 | +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?** |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +## What stays exactly the same |
| 10 | + |
| 11 | +Before the architecture, the contract that doesn't move: |
| 12 | + |
| 13 | +- The plugin still emits `og:*`, `twitter:*`, and Schema.org JSON-LD on the public side. |
| 14 | +- Admin UI still has tabs for General / Facebook / Twitter / Schema / SEO / 3rd-party / Tools. |
| 15 | +- All current settings keys (`fb_image_use_specific`, `fb_twitter_card_type`, etc.) stay valid; existing installs upgrade without losing their config. |
| 16 | +- WooCommerce product handling, Yoast/AIOSEO compatibility, Facebook locale lookup, FB cache-clear on save — all still there. |
| 17 | +- The plugin header in `wonderm00n-open-graph.php` is unchanged; WordPress still recognizes it. |
| 18 | + |
| 19 | +What changes is *where each piece of work lives in the source tree*, not what the user gets. |
| 20 | + |
| 21 | +## The profile |
| 22 | + |
| 23 | +```toml |
| 24 | +sama_version = "2.0" |
| 25 | +profile = "og-plugin" |
| 26 | + |
| 27 | +[layers.0] |
| 28 | +prefixes = ["a_"] |
| 29 | + |
| 30 | +[layers.1] |
| 31 | +sublayers = [ |
| 32 | + { name = "policy", prefix = "b1_" }, |
| 33 | + { name = "service", prefix = "b2_" }, |
| 34 | +] |
| 35 | + |
| 36 | +[layers.2] |
| 37 | +sublayers = [ |
| 38 | + { name = "repository", prefix = "c1_" }, # WP options + post meta + transients |
| 39 | + { name = "gateway", prefix = "c2_" }, # outbound HTTP (Facebook API, locale XML, image probe) |
| 40 | + { name = "controller", prefix = "c3_" }, # parses admin form posts + extracts PageContext from WP request |
| 41 | +] |
| 42 | + |
| 43 | +[layers.3] |
| 44 | +prefixes = ["d_"] |
| 45 | +``` |
| 46 | + |
| 47 | +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. |
| 48 | + |
| 49 | +## The directory |
| 50 | + |
| 51 | +``` |
| 52 | +wonderm00ns-simple-facebook-open-graph-tags/ |
| 53 | +├── wonderm00n-open-graph.php # 5 lines: WP header + require __DIR__.'/src/d_main.php' |
| 54 | +├── src/ |
| 55 | +│ ├── a_meta_tag.php # MetaTag value type (name|property + content + emitter) |
| 56 | +│ ├── a_page_context.php # PageContext: title, desc, image, locale, type, url, dates |
| 57 | +│ ├── a_settings.php # typed Settings (replaces the current mixed-array option) |
| 58 | +│ ├── a_image_descriptor.php # ImageDescriptor: url + width + height + mime |
| 59 | +│ ├── a_locale.php # Locale value type (WP code + FB code mapping) |
| 60 | +│ ├── a_post_target.php # PostTarget: post_id, post_type, is_home, is_archive |
| 61 | +│ ├── a_ports.php # interfaces: SettingsRepo, PostMetaRepo, ImageCacheRepo, |
| 62 | +│ │ # FacebookGateway, LocaleXmlGateway, |
| 63 | +│ │ # ImageProbeGateway, Clock |
| 64 | +│ │ |
| 65 | +│ ├── b1_image_selection_policy.php # which image: specific > featured > content > media > default |
| 66 | +│ ├── b1_description_policy.php # char limit + fallback chain (excerpt > content > default) |
| 67 | +│ ├── b1_locale_policy.php # WP locale → FB locale code |
| 68 | +│ ├── b1_tag_visibility_policy.php # settings-driven: which tags to emit on which page types |
| 69 | +│ ├── b1_url_policy.php # canonical URL + trailing-slash handling |
| 70 | +│ │ |
| 71 | +│ ├── b2_og_tag_service.php # build OpenGraphTags from (PageContext, Settings) |
| 72 | +│ ├── b2_twitter_tag_service.php # build TwitterCardTags |
| 73 | +│ ├── b2_schema_tag_service.php # build SchemaOrgTags JSON-LD |
| 74 | +│ ├── b2_woocommerce_service.php # WC product → PageContext enrichment |
| 75 | +│ ├── b2_settings_service.php # read/write settings with validation |
| 76 | +│ ├── b2_facebook_cache_service.php # clear FB cache after post save (orchestrates the gateway call) |
| 77 | +│ │ |
| 78 | +│ ├── c1_settings_repo.php # wraps get_option/update_option for 'wonderm00n_open_graph_settings' |
| 79 | +│ ├── c1_post_meta_repo.php # wraps get/update_post_meta for per-post overrides |
| 80 | +│ ├── c1_image_cache_repo.php # wraps transients for image-size cache |
| 81 | +│ │ |
| 82 | +│ ├── c2_facebook_gateway.php # FB Graph debug API call via wp_remote_post |
| 83 | +│ ├── c2_locale_xml_gateway.php # bundled FacebookLocales.xml + optional refresh from facebook.com |
| 84 | +│ ├── c2_image_probe_gateway.php # wp_remote_get HEAD to determine image dimensions |
| 85 | +│ │ |
| 86 | +│ ├── c3_admin_form_controller.php # parses settings save $_POST into a typed Settings update |
| 87 | +│ ├── c3_page_context_controller.php # extracts PageContext from the current WP request |
| 88 | +│ │ # (get_queried_object, $post, is_singular, ...) |
| 89 | +│ │ |
| 90 | +│ ├── d_main.php # boots autoloader, constructs adapters, wires deps |
| 91 | +│ ├── d_frontend_hooks.php # wp_head: emit OG/Twitter/Schema tags |
| 92 | +│ ├── d_admin_hooks.php # admin_menu, admin_init, settings page registration |
| 93 | +│ ├── d_admin_pages_view.php # the admin tab markup (was 2,067 LOC across 11 files) |
| 94 | +│ ├── d_post_save_hooks.php # save_post → b2_facebook_cache_service.clearForUrl() |
| 95 | +│ ├── d_woocommerce_hooks.php # WC-specific filters |
| 96 | +│ └── d_third_party_hooks.php # Yoast/AIOSEO/etc. compatibility filters |
| 97 | +│ |
| 98 | +├── lang/ # unchanged: .mo/.po translation files |
| 99 | +├── assets/ # unchanged: admin CSS + JS bundles |
| 100 | +└── tests/ # NEW: PHPUnit, one *.test.php per b*_ and c*_ file |
| 101 | + ├── b1_image_selection_policy.test.php |
| 102 | + ├── b2_og_tag_service.test.php |
| 103 | + ├── ... (one per source file in layers 1 and 2) |
| 104 | +``` |
| 105 | + |
| 106 | +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. |
| 107 | + |
| 108 | +## Layer 0 — what stops being implicit |
| 109 | + |
| 110 | +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. |
| 111 | + |
| 112 | +Under v2, those become explicit types: |
| 113 | + |
| 114 | +```php |
| 115 | +// a_meta_tag.php |
| 116 | +final class MetaTag { |
| 117 | + public function __construct( |
| 118 | + public readonly string $kind, // 'property' | 'name' | 'itemprop' |
| 119 | + public readonly string $key, // 'og:title', 'twitter:card', ... |
| 120 | + public readonly string $content, |
| 121 | + ) {} |
| 122 | +} |
| 123 | + |
| 124 | +// a_page_context.php — everything the tag services need to know about |
| 125 | +// the page being rendered, in one immutable struct |
| 126 | +final class PageContext { |
| 127 | + public function __construct( |
| 128 | + public readonly PostTarget $target, |
| 129 | + public readonly string $title, |
| 130 | + public readonly string $description, |
| 131 | + public readonly string $url, |
| 132 | + public readonly ?ImageDescriptor $image, |
| 133 | + public readonly Locale $locale, |
| 134 | + public readonly string $type, |
| 135 | + public readonly ?string $publishedAtIso, |
| 136 | + public readonly ?string $modifiedAtIso, |
| 137 | + public readonly ?string $authorName, |
| 138 | + public readonly array $sections, |
| 139 | + ) {} |
| 140 | +} |
| 141 | + |
| 142 | +// a_ports.php — interfaces Layer 1 calls through |
| 143 | +interface SettingsRepository { |
| 144 | + public function load(): Settings; |
| 145 | + public function save(Settings $settings): void; |
| 146 | +} |
| 147 | + |
| 148 | +interface FacebookGateway { |
| 149 | + public function clearCacheForUrl(string $url): FacebookResult; |
| 150 | +} |
| 151 | + |
| 152 | +interface ImageProbeGateway { |
| 153 | + public function probe(string $imageUrl): ?ImageDescriptor; |
| 154 | +} |
| 155 | + |
| 156 | +interface Clock { public function nowIso(): string; } |
| 157 | +``` |
| 158 | + |
| 159 | +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. |
| 160 | + |
| 161 | +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. |
| 162 | + |
| 163 | +## Layer 1 — what the policy/service split buys |
| 164 | + |
| 165 | +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. |
| 166 | + |
| 167 | +Under v2 they split into eleven files, each ~150-300 lines, each with a sibling test: |
| 168 | + |
| 169 | +```php |
| 170 | +// b1_image_selection_policy.php — pure decision, no I/O |
| 171 | +final class ImageSelectionPolicy { |
| 172 | + public static function pick(PageContext $ctx, Settings $settings, array $candidates): ?ImageDescriptor { |
| 173 | + // candidates is the result of a query to the database/post-meta — |
| 174 | + // selection logic only operates on the already-fetched list |
| 175 | + if ($settings->useSpecific && ($candidates['specific'] ?? null) !== null) return $candidates['specific']; |
| 176 | + if ($settings->useFeatured && ($candidates['featured'] ?? null) !== null) return $candidates['featured']; |
| 177 | + if ($settings->useContent && ($candidates['content'] ?? null) !== null) return $candidates['content']; |
| 178 | + if ($settings->useMedia && ($candidates['media'] ?? null) !== null) return $candidates['media']; |
| 179 | + if ($settings->useDefault && ($candidates['default'] ?? null) !== null) return $candidates['default']; |
| 180 | + return null; |
| 181 | + } |
| 182 | +} |
| 183 | + |
| 184 | +// b2_og_tag_service.php — orchestrator. Takes ports at construction. |
| 185 | +final class OgTagService { |
| 186 | + public function __construct( |
| 187 | + private SettingsRepository $settings, |
| 188 | + private PostMetaRepository $postMeta, |
| 189 | + private ImageProbeGateway $imageProbe, |
| 190 | + private TagVisibilityPolicy $visibility, |
| 191 | + ) {} |
| 192 | + |
| 193 | + public function buildTagsFor(PageContext $ctx): array { |
| 194 | + $settings = $this->settings->load(); |
| 195 | + if (!$this->visibility->shouldEmitOg($ctx, $settings)) return []; |
| 196 | + $tags = []; |
| 197 | + if ($settings->titleShow) $tags[] = new MetaTag('property', 'og:title', $ctx->title); |
| 198 | + if ($settings->urlShow) $tags[] = new MetaTag('property', 'og:url', $ctx->url); |
| 199 | + if ($settings->descShow) $tags[] = new MetaTag('property', 'og:description', $ctx->description); |
| 200 | + if ($settings->imageShow && $ctx->image) |
| 201 | + $tags[] = new MetaTag('property', 'og:image', $ctx->image->url); |
| 202 | + if ($settings->localeShow) $tags[] = new MetaTag('property', 'og:locale', $ctx->locale->fbCode); |
| 203 | + // ... ~25 lines, each line one well-named conditional |
| 204 | + return $tags; |
| 205 | + } |
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | +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. |
| 210 | + |
| 211 | +## Layer 2 — every boundary in exactly one place |
| 212 | + |
| 213 | +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. |
| 214 | + |
| 215 | +```php |
| 216 | +// c3_page_context_controller.php — turns the current WP request into PageContext. |
| 217 | +// THIS is the only file that calls get_queried_object, $post, is_singular, etc. |
| 218 | +final class PageContextController { |
| 219 | + public function __construct( |
| 220 | + private PostMetaRepository $postMeta, |
| 221 | + private LocalePolicy $localePolicy, |
| 222 | + private ImageProbeGateway $imageProbe, |
| 223 | + private Clock $clock, |
| 224 | + ) {} |
| 225 | + |
| 226 | + public function extract(): PageContext { |
| 227 | + global $post; |
| 228 | + $queried = get_queried_object(); |
| 229 | + $target = new PostTarget( |
| 230 | + postId: $post->ID ?? 0, |
| 231 | + postType: get_post_type() ?: '', |
| 232 | + isHome: is_front_page(), |
| 233 | + isArchive: is_archive(), |
| 234 | + ); |
| 235 | + // ... build PageContext field by field, with all the WP boundary calls |
| 236 | + // (get_the_title, get_permalink, get_the_excerpt, $post->post_modified, ...) |
| 237 | + // happening here ONLY |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +// c1_settings_repo.php |
| 242 | +final class WpOptionSettingsRepository implements SettingsRepository { |
| 243 | + public function load(): Settings { |
| 244 | + $raw = get_option('wonderm00n_open_graph_settings', []); |
| 245 | + // map raw array to typed Settings, validating + applying defaults |
| 246 | + } |
| 247 | + public function save(Settings $settings): void { |
| 248 | + update_option('wonderm00n_open_graph_settings', $settings->toArray()); |
| 249 | + } |
| 250 | +} |
| 251 | + |
| 252 | +// c2_facebook_gateway.php — outbound HTTP, isolated |
| 253 | +final class FacebookGraphGateway implements FacebookGateway { |
| 254 | + public function __construct(private string $appId, private string $appSecret) {} |
| 255 | + |
| 256 | + public function clearCacheForUrl(string $url): FacebookResult { |
| 257 | + $response = wp_remote_post( |
| 258 | + "https://graph.facebook.com/?id={$url}&scrape=true&access_token={$this->appId}|{$this->appSecret}" |
| 259 | + ); |
| 260 | + if (is_wp_error($response)) return FacebookResult::error($response->get_error_message()); |
| 261 | + $body = json_decode(wp_remote_retrieve_body($response), true); |
| 262 | + return is_array($body) ? FacebookResult::ok($body) : FacebookResult::error('non-JSON body'); |
| 263 | + } |
| 264 | +} |
| 265 | +``` |
| 266 | + |
| 267 | +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. |
| 268 | + |
| 269 | +## Layer 3 — wiring only |
| 270 | + |
| 271 | +Hook registrations live in `d_*.php`. Every callback is the same shape: parse → service → return. |
| 272 | + |
| 273 | +```php |
| 274 | +// d_frontend_hooks.php |
| 275 | +add_action('wp_head', function () { |
| 276 | + global $og_plugin_deps; |
| 277 | + $ctx = $og_plugin_deps->pageContext->extract(); |
| 278 | + $ogTags = $og_plugin_deps->ogService->buildTagsFor($ctx); |
| 279 | + $twTags = $og_plugin_deps->twService->buildTagsFor($ctx); |
| 280 | + $schemaTags = $og_plugin_deps->schemaService->buildTagsFor($ctx); |
| 281 | + echo render_meta_tags($ogTags); // pure string concat from MetaTag[] |
| 282 | + echo render_meta_tags($twTags); |
| 283 | + echo render_schema_jsonld($schemaTags); |
| 284 | +}, 5); |
| 285 | + |
| 286 | +// d_post_save_hooks.php |
| 287 | +add_action('save_post', function (int $postId) { |
| 288 | + if (wp_is_post_revision($postId)) return; |
| 289 | + global $og_plugin_deps; |
| 290 | + $url = get_permalink($postId); |
| 291 | + $og_plugin_deps->fbCacheService->clearForUrl($url); // b2 service, not gateway directly |
| 292 | +}, 10, 1); |
| 293 | +``` |
| 294 | + |
| 295 | +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. |
| 296 | + |
| 297 | +## What the §5 metrics would look like |
| 298 | + |
| 299 | +Estimated for the refactored plugin, alongside what the original would report: |
| 300 | + |
| 301 | +| metric | original plugin | v2-refactored plugin | tdd.md (measured) | |
| 302 | +|---|---|---|---| |
| 303 | +| §4 checks passing | 0 / 7 | **7 / 7** | 7 / 7 | |
| 304 | +| graphDepth | ~3 | ~5 (entry → service → policy → port → adapter) | 7 | |
| 305 | +| boundaryRatio | <10% | **100%** | 100% | |
| 306 | +| workingSetFit (50–500 LOC) | ~47% | **~95%** | 80% | |
| 307 | +| violationCounts (sum) | 17+ | **0** | 0 | |
| 308 | + |
| 309 | +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. |
| 310 | + |
| 311 | +## What this isn't |
| 312 | + |
| 313 | +This is a thought experiment, not a pull request. |
| 314 | + |
| 315 | +- 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. |
| 316 | +- 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. |
| 317 | +- "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. |
| 318 | + |
| 319 | +What the sketch buys is the same thing the [§5 metrics emitter](/blog/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. |
| 320 | + |
| 321 | +--- |
| 322 | + |
| 323 | +**Companion posts:** |
| 324 | + |
| 325 | +- [The audit](/blog/sama-v2-wordpress-plugin-audit) — pointing v2 at the real plugin: 0 of 7 checks pass, why that's the expected baseline |
| 326 | +- [The generic WP profile](/sama/v2/example-wordpress) — a hypothetical event-registration plugin under v2 from scratch |
| 327 | +- [The §5 metrics emitter](/blog/sama-v2-metrics-emitter) — the empirical artefact §6 requires before any later claim can be measured as a delta |
| 328 | +- [The v2 verifier post](/blog/sama-v2-verifier-and-the-rename) — yesterday's piece on building the verifier itself |