<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pratap</title>
    <description>The latest articles on DEV Community by Pratap (@pratap_h).</description>
    <link>https://dev.to/pratap_h</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3924351%2Fa6bed2c2-ee47-4ded-a797-3b4a2eb998f7.png</url>
      <title>DEV Community: Pratap</title>
      <link>https://dev.to/pratap_h</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pratap_h"/>
    <language>en</language>
    <item>
      <title>The PHP Stack I Built TrustGate On — And Why I'd Do It Differently Today</title>
      <dc:creator>Pratap</dc:creator>
      <pubDate>Sat, 23 May 2026 18:36:44 +0000</pubDate>
      <link>https://dev.to/pratap_h/the-php-stack-i-built-trustgate-on-and-why-id-do-it-differently-today-397a</link>
      <guid>https://dev.to/pratap_h/the-php-stack-i-built-trustgate-on-and-why-id-do-it-differently-today-397a</guid>
      <description>&lt;p&gt;I built &lt;a href="https://trustgate.in" rel="noopener noreferrer"&gt;TrustGate&lt;/a&gt; — India's independent business review platform — entirely as a custom WordPress plugin.&lt;/p&gt;

&lt;p&gt;Full REST API. Custom database tables with versioned migrations. Cryptographically secure random tokens for a live embeddable badge system. A bulk email campaign engine. Claim verification flows with fraud detection, appeal systems, and audit trails. JSON-LD schema markup on every business profile page.&lt;/p&gt;

&lt;p&gt;All of it. Solo. In PHP. On WordPress.&lt;/p&gt;

&lt;p&gt;This is not a tutorial. This is an honest breakdown of the architectural decisions I made, the ones that hurt me, and what I'd tell you before you write a single line.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WordPress (latest)
├── Custom Plugin — trustgate-reviews
│   ├── core/
│   │   ├── class-database.php       — DB tables + versioned migrations
│   │   └── class-reviews.php        — Business/review CRUD
│   ├── includes/
│   │   ├── class-rest-api.php       — Route registration
│   │   ├── trait-rest-api-businesses.php
│   │   ├── trait-rest-api-reviews.php
│   │   ├── trait-rest-api-claims.php
│   │   ├── trait-rest-api-badge.php
│   │   ├── trait-rest-api-helpers.php
│   │   └── trait-rest-api-auth-admin.php
│   ├── admin/
│   │   ├── class-admin.php          — Menu registration
│   │   ├── class-admin-pages.php    — All admin page methods (3,200 lines)
│   │   └── partials/                — Admin page templates
│   └── public/
│       ├── templates/               — Custom page templates
│       │   ├── single-business.php
│       │   ├── for-businesses.php
│       │   ├── pricing.php
│       │   └── partials/
│       └── js/
│           └── trustgate-badge.js   — The embeddable badge widget
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database layer has its own versioned migration system — not relying on &lt;code&gt;dbDelta&lt;/code&gt; alone. Every schema change goes through &lt;code&gt;maybe_run_migrations()&lt;/code&gt;, which checks a version option and runs incrementally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;maybe_run_migrations&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trustgate_db_migration_version'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;version_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1.1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Add sub_ratings + experience_tags columns&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;version_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1.2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Create badge_applications + badge_activations tables&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;version_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1.3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Add response_updated_at to reviews&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;version_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1.4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Add campaign_emailed_at to businesses&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trustgate_db_migration_version'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1.4'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the schema in sync across environments without manual SQL runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Decision I'd Revisit
&lt;/h2&gt;

&lt;p&gt;I built the entire admin interface inside a single PHP class — &lt;code&gt;class-admin-pages.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At launch: ~800 lines. Every method was readable, the file was navigable, and I felt in control.&lt;/p&gt;

&lt;p&gt;By the time I added the badge system, campaign email engine, claim appeals, fraud detection, and the badge applications admin panel — it was &lt;strong&gt;3,200 lines in a single file&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every new feature meant scrolling through thousands of lines to find the right method. Every bug fix risked breaking something three functions away. A &lt;code&gt;str_replace&lt;/code&gt; gone wrong during a bulk edit once nuked a working modal and cost me two hours.&lt;/p&gt;

&lt;p&gt;The fix was obvious in hindsight: &lt;strong&gt;split into traits from day one&lt;/strong&gt;. I eventually did this for the REST API layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Trustgate_Reviews_REST_API&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;Trustgate_REST_API_Businesses_Trait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;Trustgate_REST_API_Reviews_Trait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;Trustgate_REST_API_Claims_Trait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;Trustgate_REST_API_Badge_Trait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;Trustgate_REST_API_Helpers_Trait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nf"&gt;Trustgate_REST_API_Auth_Admin_Trait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each trait owns exactly one domain. &lt;code&gt;trait-rest-api-badge.php&lt;/code&gt; handles everything badge-related — route registration, apply/approve/reject endpoints, domain whitelisting, email templates, the campaign send engine. It's ~600 lines and a pleasure to work in.&lt;/p&gt;

&lt;p&gt;The admin class still isn't split. It's on the roadmap. It's also my biggest current regret.&lt;/p&gt;




&lt;h2&gt;
  
  
  The WordPress Decision
&lt;/h2&gt;

&lt;p&gt;I chose WordPress + custom PHP plugin instead of a headless Next.js + Node architecture. Most people in my network said that was the wrong call for a platform product.&lt;/p&gt;

&lt;p&gt;They were partially right — and entirely wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What WordPress gave me for free on day one:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication, user roles, capability checks — &lt;code&gt;current_user_can('manage_options')&lt;/code&gt;, done&lt;/li&gt;
&lt;li&gt;Media handling — logo/banner uploads without writing a single upload handler&lt;/li&gt;
&lt;li&gt;WP-Cron — scheduled tasks without a separate worker process&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wp_mail()&lt;/code&gt; — transactional email through the host's SMTP, no Sendgrid setup&lt;/li&gt;
&lt;li&gt;WP REST API — production-ready, versioned, nonce-authenticated endpoints out of the box&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$wpdb-&amp;gt;prepare()&lt;/code&gt; — parameterised queries with a clean API&lt;/li&gt;
&lt;li&gt;Plugin activation hooks — &lt;code&gt;register_activation_hook&lt;/code&gt; runs &lt;code&gt;dbDelta&lt;/code&gt; to create tables on install&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The alternative would have been two months of infrastructure before I wrote a single domain-specific line of code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it cost me:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fighting Astra theme layout conflicts on custom template pages. The pattern that worked — close Astra's containers after &lt;code&gt;get_header()&lt;/code&gt;, output your content freely, reopen before &lt;code&gt;get_footer()&lt;/code&gt; — took two days to figure out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;get_header();
?&amp;gt;
&lt;span class="c"&gt;&amp;lt;!-- Close Astra containers --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/main&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"my-page-wrap"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Custom page content here --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Reopen Astra containers for footer --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"site-main"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ast-container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ast-row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt; &lt;span class="nf"&gt;get_footer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="cp"&gt;?&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not elegant. But it works with any Astra child theme without touching theme files.&lt;/p&gt;

&lt;p&gt;The other cost: &lt;code&gt;wp_magic_quotes()&lt;/code&gt;. WordPress applies &lt;code&gt;addslashes()&lt;/code&gt; to all &lt;code&gt;$_POST&lt;/code&gt; / &lt;code&gt;$_GET&lt;/code&gt; data — including REST API request bodies. Which means a business name like &lt;code&gt;Manoj's Geography Classes&lt;/code&gt; gets stored in the DB as &lt;code&gt;Manoj\'s Geography Classes&lt;/code&gt; unless you explicitly call &lt;code&gt;wp_unslash()&lt;/code&gt; before saving.&lt;/p&gt;

&lt;p&gt;I found this bug after a business owner reported garbled text on their profile page. The fix was one line. The diagnosis took longer than it should have because I assumed &lt;code&gt;sanitize_text_field()&lt;/code&gt; handled it — it doesn't. The correct pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;wp_unslash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'business_name'&lt;/span&gt;&lt;span class="p"&gt;])),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always &lt;code&gt;wp_unslash()&lt;/code&gt; first, then sanitize.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Badge System
&lt;/h2&gt;

&lt;p&gt;The feature I'm most proud of is the TrustGate Verified Badge.&lt;/p&gt;

&lt;p&gt;Business owners claim their profile, get verified by admin, apply for the badge, and receive an approval email with embed codes. They paste one line of HTML into their website. The badge renders live data — star rating, review count — fetched from our REST API using a cryptographically secure random token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Token generation on badge approval&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 64-char hex token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The badge JS is under 4KB, async-loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"tg-badge"&lt;/span&gt; &lt;span class="na"&gt;data-token=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_TOKEN"&lt;/span&gt; &lt;span class="na"&gt;data-style=&lt;/span&gt;&lt;span class="s"&gt;"compact"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://trustgate.in/wp-content/plugins/trustgate-reviews/public/js/trustgate-badge.js"&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live data endpoint validates the token and — critically — enforces domain whitelisting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_badge_live_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$token&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'token'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$activation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"SELECT business_id, embed_domain FROM &lt;/span&gt;&lt;span class="nv"&gt;$activations_table&lt;/span&gt;&lt;span class="s2"&gt;
         WHERE token = %s AND is_active = 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$token&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$activation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invalid_token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Invalid token.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Domain whitelisting — prevent token theft&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$activation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;embed_domain&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$allowed&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^www\./i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$activation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;embed_domain&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$raw_origin&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'HTTP_ORIGIN'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'HTTP_REFERER'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$request_host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^www\./i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw_origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;PHP_URL_HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request_host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$request_host&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'domain_not_allowed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Unauthorised domain.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Return live data...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, any competitor could scrape a badge token from the page source and embed another business's ratings on their own site.&lt;/p&gt;




&lt;h2&gt;
  
  
  What TrustGate Has Today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verified business profiles with GST/MCA entity grounding&lt;/li&gt;
&lt;li&gt;Review submission with sub-ratings, experience tags, and consent gates&lt;/li&gt;
&lt;li&gt;Owner dashboard — respond to reviews, edit responses with audit timestamps&lt;/li&gt;
&lt;li&gt;Full moderation pipeline — approve/reject with email notifications, fraud flagging, appeal system with 3-attempt blocking&lt;/li&gt;
&lt;li&gt;TrustGate Verified Badge — 3 embed styles, live API, domain whitelisting, 1,000 founding partner limit&lt;/li&gt;
&lt;li&gt;Badge campaign system — bulk/single email targeting unclaimed businesses, TinyMCE body editor, placeholder chips, batch sending with progress bar&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/for-businesses/&lt;/code&gt; and &lt;code&gt;/pricing/&lt;/code&gt; as WordPress template overrides with full SEO schema&lt;/li&gt;
&lt;li&gt;RankMath integration — custom title/description per business profile, schema disabled selectively per page type&lt;/li&gt;
&lt;li&gt;JSON-LD schema — &lt;code&gt;LocalBusiness&lt;/code&gt;, &lt;code&gt;AggregateRating&lt;/code&gt;, &lt;code&gt;Review&lt;/code&gt;, &lt;code&gt;FAQPage&lt;/code&gt;, &lt;code&gt;BreadcrumbList&lt;/code&gt; on every business page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it in a single WordPress plugin. All of it solo.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Tell You Before You Write a Single Line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't let the stack be your first debate. Let the problem be your first debate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I've watched teams spend three months choosing between Next.js and Remix for products that had zero validated demand. I spent that time building.&lt;/p&gt;

&lt;p&gt;Pick the stack that gets you to your first 100 real users. You can refactor architecture. You cannot un-waste six months building infrastructure for a product nobody is using yet.&lt;/p&gt;

&lt;p&gt;The architecture that matters most is the one that ships.&lt;/p&gt;




&lt;p&gt;If you're building a review platform, a directory, or any consumer-facing product — I'm happy to talk through specific decisions. Drop a comment or find me on &lt;a href="https://www.linkedin.com/in/pratap-halder/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if you have a business listed anywhere in India — check if you're already on &lt;a href="https://trustgate.in" rel="noopener noreferrer"&gt;TrustGate&lt;/a&gt;. Our founding partner badge program is free for the first 1,000 verified businesses.&lt;/p&gt;

</description>
      <category>php</category>
      <category>wordpress</category>
      <category>architecture</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building a Business Review Platform on WordPress with REST API — What I Learned</title>
      <dc:creator>Pratap</dc:creator>
      <pubDate>Mon, 11 May 2026 10:50:03 +0000</pubDate>
      <link>https://dev.to/pratap_h/building-a-business-review-platform-on-wordpress-with-rest-api-what-i-learned-3i41</link>
      <guid>https://dev.to/pratap_h/building-a-business-review-platform-on-wordpress-with-rest-api-what-i-learned-3i41</guid>
      <description>&lt;p&gt;I built TrustGate from scratch — a business review and trust platform for the Indian market. Think verified business profiles, customer reviews, owner responses, and an embeddable trust badge that pulls live data.&lt;/p&gt;

&lt;p&gt;The entire backend runs on a custom WordPress plugin using the REST API exclusively. No &lt;code&gt;admin-ajax.php&lt;/code&gt;. Not a single call to it.&lt;/p&gt;

&lt;p&gt;This post is about what I learned building it, what broke, what I got wrong the first time, and the decisions that actually held up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why REST API and not admin-ajax
&lt;/h2&gt;

&lt;p&gt;When I started, the easy path was &lt;code&gt;admin-ajax.php&lt;/code&gt;. Every tutorial uses it. It works. But it has real problems for a platform of this type.&lt;/p&gt;

&lt;p&gt;Every request, regardless of whether a user is logged in or not, boots the entire WordPress admin. That is expensive. For a review platform where anonymous users are searching and browsing businesses constantly, that overhead adds up fast.&lt;/p&gt;

&lt;p&gt;The REST API routes only load what they need. Public endpoints run lean. Authenticated endpoints check permissions early and bail out before doing unnecessary work.&lt;/p&gt;

&lt;p&gt;The other reason is structure. &lt;code&gt;admin-ajax.php&lt;/code&gt; encourages you to dump everything into one giant handler function. The REST API forces you into proper route registration with explicit HTTP methods, permission callbacks, and sanitization callbacks — all separated. Your code ends up cleaner whether you like it or not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The basic structure I settled on
&lt;/h2&gt;

&lt;p&gt;Everything lives under a versioned namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rest_api_init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;register_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'trustgate/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/businesses'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'callback'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tg_get_businesses'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'__return_true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nf"&gt;register_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'trustgate/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/reviews'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'callback'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tg_submit_review'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tg_is_logged_in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public routes like business listings get &lt;code&gt;__return_true&lt;/code&gt; as the permission callback. Anything that writes data — submitting a review, claiming a business, owner responses — requires authentication.&lt;/p&gt;

&lt;p&gt;The permission callback runs before the main callback. If it returns false, WordPress automatically returns a 403 before your main function even runs. This is clean and hard to accidentally skip.&lt;/p&gt;




&lt;h2&gt;
  
  
  The NULL safety problem I kept hitting
&lt;/h2&gt;

&lt;p&gt;The platform stores a lot of optional data. Business logos, banner images, contact numbers, website URLs — not every business has all of these filled in. Early on I was doing things like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$logo_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$business_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tg_logo_url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;img src="'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$logo_url&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'"&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which works until it doesn't. Empty strings, NULL values from the database, and unset meta keys all behave slightly differently in PHP depending on context. The approach that fixed this completely was wrapping every meta retrieval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tg_get_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$default&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; 
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; 
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$default&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One function. Every meta call goes through it. No more inconsistent empty values breaking JSON responses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sanitization and validation are not the same thing
&lt;/h2&gt;

&lt;p&gt;This distinction cost me time. I was mixing them up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation&lt;/strong&gt; checks whether the input is acceptable. If it fails, you return an error.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Sanitization&lt;/strong&gt; cleans the input. You use it when you'd rather transform bad input than reject it.&lt;/p&gt;

&lt;p&gt;For review submissions, the star rating should be validated — it must be an integer between 1 and 5, and if it isn't, the request should fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;register_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'trustgate/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/reviews'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'methods'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tg_submit_review'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tg_is_logged_in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'args'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'rating'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'required'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'validate_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;is_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; 
                    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; 
                    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'review_text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'required'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_textarea_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Review text gets sanitized — strip any HTML, clean whitespace, but don't reject the whole request over formatting. Rating gets validated strictly. This split makes the API predictable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handling the trust badge
&lt;/h2&gt;

&lt;p&gt;The embeddable badge was the most interesting technical piece to build.&lt;/p&gt;

&lt;p&gt;Each verified business gets a badge they can paste on their website — a small script tag that renders their current TrustGate rating and review count dynamically. The script hits a public REST endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /wp-json/trustgate/v1/badge/{business_id}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"business_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Business"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"review_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;83&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"badge_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://trustgate.in/business/example-business"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The embedded script fetches this and renders the badge in the business's own website. Under 4KB. Loads asynchronously so it has zero impact on the host page's performance.&lt;/p&gt;

&lt;p&gt;The key decision here was keeping this endpoint completely public and cached aggressively. No authentication, no session lookup, just a fast database read and a JSON response. Response time for this endpoint matters more than almost anything else because it affects websites that are not even ours.&lt;/p&gt;




&lt;h2&gt;
  
  
  The claim verification workflow
&lt;/h2&gt;

&lt;p&gt;Business owners can claim their profile by submitting documents — GST certificate, MCA filing, Udyam certificate. The claim goes into a pending queue that an admin reviews manually.&lt;/p&gt;

&lt;p&gt;I built a three-attempt limit into this. After three failed or rejected claim attempts, the user account is flagged and the claim form is blocked. This was necessary because some users were submitting incomplete documentation repeatedly without fixing anything.&lt;/p&gt;

&lt;p&gt;The flag status is stored in user meta and checked by a permission callback before the claim endpoint even processes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;tg_can_submit_claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_user_logged_in&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; 
            &lt;span class="s1"&gt;'not_logged_in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
            &lt;span class="s1"&gt;'You must be logged in.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
            &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; 
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; 
        &lt;span class="nf"&gt;get_current_user_id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; 
        &lt;span class="s1"&gt;'tg_claim_attempts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
        &lt;span class="kc"&gt;true&lt;/span&gt; 
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; 
            &lt;span class="s1"&gt;'claim_blocked'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
            &lt;span class="s1"&gt;'Maximum claim attempts reached.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
            &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; 
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning a &lt;code&gt;WP_Error&lt;/code&gt; from a permission callback sends the right HTTP status code automatically. The client receives a 403 with a clear message. No extra handling needed in the main callback.&lt;/p&gt;




&lt;h2&gt;
  
  
  Structured data and SEO
&lt;/h2&gt;

&lt;p&gt;Every business profile page outputs JSON-LD structured data built from the REST API response. The review schema tells Google about the aggregate rating, individual reviews, and the business itself.&lt;/p&gt;

&lt;p&gt;This was a deliberate architecture decision. The data driving the structured data markup comes from the same place as the data driving the page — the REST API. No duplication, no chance of them going out of sync.&lt;/p&gt;

&lt;p&gt;The platform started ranking on Google within weeks of launch. Positions 2 to 7 for target keywords with zero advertising. Structured data was a significant factor.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;A few things I would do differently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom tables vs post meta.&lt;/strong&gt; Reviews are stored as a custom post type with meta. This made sense early on for simplicity but it is getting slow as volume grows. Custom database tables with proper indexing would have been faster and easier to query for aggregate data like average ratings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching too late.&lt;/strong&gt; I added object caching to expensive queries after I noticed slowdowns rather than designing for it from the start. Building cache invalidation into the write endpoints from day one would have saved refactoring time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not versioning endpoints sooner.&lt;/strong&gt; The &lt;code&gt;/trustgate/v1/&lt;/code&gt; namespace is there from the start, which is good. But I did not think carefully about what a v2 would need to change until v1 was already embedded in the badge scripts on other websites. Versioning is cheap to add and expensive to skip.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one thing that helped most
&lt;/h2&gt;

&lt;p&gt;Treating every endpoint like a public API even when it was only being used internally. That means proper HTTP status codes, consistent JSON response shapes, explicit error messages, and no shortcuts on permission checks.&lt;/p&gt;

&lt;p&gt;When you do this, debugging becomes straightforward. The browser dev tools tell you exactly what went wrong and why. Frontend code can rely on the response structure. And when you eventually do open an endpoint to external use, you have nothing to fix.&lt;/p&gt;

&lt;p&gt;WordPress gets dismissed as a toy by a lot of developers. The REST API is genuinely good. The constraint is not the framework — it is whether you treat it seriously.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;TrustGate is live at &lt;a href="https://trustgate.in" rel="noopener noreferrer"&gt;trustgate.in&lt;/a&gt;. Feedback welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
