I built TrustGate — India's independent business review platform — entirely as a custom WordPress plugin.
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.
All of it. Solo. In PHP. On WordPress.
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.
The Stack
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
The database layer has its own versioned migration system — not relying on dbDelta alone. Every schema change goes through maybe_run_migrations(), which checks a version option and runs incrementally:
public static function maybe_run_migrations() {
$current = get_option('trustgate_db_migration_version', '0');
global $wpdb;
if (version_compare($current, '1.1', '<')) {
// Add sub_ratings + experience_tags columns
}
if (version_compare($current, '1.2', '<')) {
// Create badge_applications + badge_activations tables
}
if (version_compare($current, '1.3', '<')) {
// Add response_updated_at to reviews
}
if (version_compare($current, '1.4', '<')) {
// Add campaign_emailed_at to businesses
}
update_option('trustgate_db_migration_version', '1.4');
}
This keeps the schema in sync across environments without manual SQL runs.
The Decision I'd Revisit
I built the entire admin interface inside a single PHP class — class-admin-pages.php.
At launch: ~800 lines. Every method was readable, the file was navigable, and I felt in control.
By the time I added the badge system, campaign email engine, claim appeals, fraud detection, and the badge applications admin panel — it was 3,200 lines in a single file.
Every new feature meant scrolling through thousands of lines to find the right method. Every bug fix risked breaking something three functions away. A str_replace gone wrong during a bulk edit once nuked a working modal and cost me two hours.
The fix was obvious in hindsight: split into traits from day one. I eventually did this for the REST API layer:
class Trustgate_Reviews_REST_API {
use Trustgate_REST_API_Businesses_Trait;
use Trustgate_REST_API_Reviews_Trait;
use Trustgate_REST_API_Claims_Trait;
use Trustgate_REST_API_Badge_Trait;
use Trustgate_REST_API_Helpers_Trait;
use Trustgate_REST_API_Auth_Admin_Trait;
}
Each trait owns exactly one domain. trait-rest-api-badge.php 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.
The admin class still isn't split. It's on the roadmap. It's also my biggest current regret.
The WordPress Decision
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.
They were partially right — and entirely wrong.
What WordPress gave me for free on day one:
- Authentication, user roles, capability checks —
current_user_can('manage_options'), done - Media handling — logo/banner uploads without writing a single upload handler
- WP-Cron — scheduled tasks without a separate worker process
-
wp_mail()— transactional email through the host's SMTP, no Sendgrid setup - WP REST API — production-ready, versioned, nonce-authenticated endpoints out of the box
-
$wpdb->prepare()— parameterised queries with a clean API - Plugin activation hooks —
register_activation_hookrunsdbDeltato create tables on install
The alternative would have been two months of infrastructure before I wrote a single domain-specific line of code.
What it cost me:
Fighting Astra theme layout conflicts on custom template pages. The pattern that worked — close Astra's containers after get_header(), output your content freely, reopen before get_footer() — took two days to figure out:
get_header();
?>
<!-- Close Astra containers -->
</div></div></main>
<div class="my-page-wrap">
<!-- Custom page content here -->
</div>
<!-- Reopen Astra containers for footer -->
<main class="site-main"><div class="ast-container"><div class="ast-row">
<?php get_footer(); ?>
Not elegant. But it works with any Astra child theme without touching theme files.
The other cost: wp_magic_quotes(). WordPress applies addslashes() to all $_POST / $_GET data — including REST API request bodies. Which means a business name like Manoj's Geography Classes gets stored in the DB as Manoj\'s Geography Classes unless you explicitly call wp_unslash() before saving.
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 sanitize_text_field() handled it — it doesn't. The correct pattern:
'name' => sanitize_text_field(wp_unslash($params['business_name'])),
Always wp_unslash() first, then sanitize.
The Badge System
The feature I'm most proud of is the TrustGate Verified Badge.
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:
// Token generation on badge approval
$token = bin2hex(random_bytes(32)); // 64-char hex token
The badge JS is under 4KB, async-loaded:
<div class="tg-badge" data-token="YOUR_TOKEN" data-style="compact"></div>
<script src="https://trustgate.in/wp-content/plugins/trustgate-reviews/public/js/trustgate-badge.js" async></script>
The live data endpoint validates the token and — critically — enforces domain whitelisting:
public function get_badge_live_data($request) {
$token = sanitize_text_field($request['token']);
$activation = $wpdb->get_row($wpdb->prepare(
"SELECT business_id, embed_domain FROM $activations_table
WHERE token = %s AND is_active = 1",
$token
));
if (!$activation) {
return new WP_Error('invalid_token', 'Invalid token.', ['status' => 404]);
}
// Domain whitelisting — prevent token theft
if (!empty($activation->embed_domain)) {
$allowed = strtolower(preg_replace('/^www\./i', '', $activation->embed_domain));
$raw_origin = $_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? '';
$request_host = strtolower(preg_replace('/^www\./i', '', parse_url($raw_origin, PHP_URL_HOST) ?? ''));
if (!empty($request_host) && $request_host !== $allowed) {
return new WP_Error('domain_not_allowed', 'Unauthorised domain.', ['status' => 403]);
}
}
// Return live data...
}
Without this, any competitor could scrape a badge token from the page source and embed another business's ratings on their own site.
What TrustGate Has Today
- Verified business profiles with GST/MCA entity grounding
- Review submission with sub-ratings, experience tags, and consent gates
- Owner dashboard — respond to reviews, edit responses with audit timestamps
- Full moderation pipeline — approve/reject with email notifications, fraud flagging, appeal system with 3-attempt blocking
- TrustGate Verified Badge — 3 embed styles, live API, domain whitelisting, 1,000 founding partner limit
- Badge campaign system — bulk/single email targeting unclaimed businesses, TinyMCE body editor, placeholder chips, batch sending with progress bar
-
/for-businesses/and/pricing/as WordPress template overrides with full SEO schema - RankMath integration — custom title/description per business profile, schema disabled selectively per page type
- JSON-LD schema —
LocalBusiness,AggregateRating,Review,FAQPage,BreadcrumbListon every business page
All of it in a single WordPress plugin. All of it solo.
What I'd Tell You Before You Write a Single Line
Don't let the stack be your first debate. Let the problem be your first debate.
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.
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.
The architecture that matters most is the one that ships.
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 LinkedIn.
And if you have a business listed anywhere in India — check if you're already on TrustGate. Our founding partner badge program is free for the first 1,000 verified businesses.
Top comments (0)