<?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: Canopus agency</title>
    <description>The latest articles on DEV Community by Canopus agency (@canopuswebagency).</description>
    <link>https://dev.to/canopuswebagency</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3988737%2F5de0a880-3276-4b7a-8830-618d0551aa93.jpg</url>
      <title>DEV Community: Canopus agency</title>
      <link>https://dev.to/canopuswebagency</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/canopuswebagency"/>
    <language>en</language>
    <item>
      <title>How to Build a WordPress Plugin Licensing System from Scratch (Without Freemius)</title>
      <dc:creator>Canopus agency</dc:creator>
      <pubDate>Wed, 17 Jun 2026 09:53:06 +0000</pubDate>
      <link>https://dev.to/canopuswebagency/how-to-build-a-wordpress-plugin-licensing-system-from-scratch-without-freemius-4l51</link>
      <guid>https://dev.to/canopuswebagency/how-to-build-a-wordpress-plugin-licensing-system-from-scratch-without-freemius-4l51</guid>
      <description>&lt;p&gt;If you're shipping a commercial WordPress plugin, sooner or later you'll need a licensing system. Something that lets paying customers activate the plugin on their site, locks it to that domain, and stops people from sharing the same key across fifty sites.&lt;/p&gt;

&lt;p&gt;The default answer in the WordPress world is Freemius or EDD Software Licensing. They're great. They're also a revenue share, a third-party dependency, and a black box you don't control.&lt;/p&gt;

&lt;p&gt;When we built &lt;strong&gt;&lt;a href="https://ridecabwp.com" rel="noopener noreferrer"&gt;RideCab WP&lt;/a&gt;&lt;/strong&gt;, a commercial WooCommerce taxi booking plugin, we decided to build our own. Here's the architecture, the code, and the gotchas we hit along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Licensing System Actually Needs to Do
&lt;/h2&gt;

&lt;p&gt;Before writing any code, get clear on the requirements. A real plugin licensing system needs to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate unique license keys when someone buys&lt;/li&gt;
&lt;li&gt;Let the customer activate the key on their site&lt;/li&gt;
&lt;li&gt;Bind that key to one (or N) domains&lt;/li&gt;
&lt;li&gt;Validate the key periodically so revoked or expired keys stop working&lt;/li&gt;
&lt;li&gt;Handle deactivation when a customer moves to a new domain&lt;/li&gt;
&lt;li&gt;Fail gracefully — never lock a paying customer out because your license server hiccuped&lt;/li&gt;
&lt;li&gt;Optionally: deliver plugin updates only to valid license holders&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We'll cover 1 through 6 in this post. Update delivery is a separate beast and I'll write it up next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The system has two halves that live in two different places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The license server&lt;/strong&gt; runs on your own infrastructure — for us, it's a WordPress must-use plugin (mu-plugin) on the same WordPress install that powers our marketing site. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stores license keys in a custom database table&lt;/li&gt;
&lt;li&gt;Exposes a small REST API for activate, deactivate, and validate calls&lt;/li&gt;
&lt;li&gt;Provides an admin dashboard to view, create, and revoke keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The client&lt;/strong&gt; is a PHP class shipped inside the commercial plugin (RideCab WP, in our case). It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adds a license settings page to the plugin&lt;/li&gt;
&lt;li&gt;Calls home on activation&lt;/li&gt;
&lt;li&gt;Caches the validation result&lt;/li&gt;
&lt;li&gt;Re-validates quietly in the background&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two pieces, talking over HTTPS, with the customer's domain as the binding key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The License Server — Database Schema
&lt;/h2&gt;

&lt;p&gt;On the server side, the simplest workable schema is one table:&lt;/p&gt;

&lt;p&gt;`php&lt;br&gt;
global $wpdb;&lt;br&gt;
$table = $wpdb-&amp;gt;prefix . 'plugin_licenses';&lt;/p&gt;

&lt;p&gt;$sql = "CREATE TABLE $table (&lt;br&gt;
    id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,&lt;br&gt;
    license_key VARCHAR(64) NOT NULL,&lt;br&gt;
    customer_email VARCHAR(191) NOT NULL,&lt;br&gt;
    product_slug VARCHAR(64) NOT NULL,&lt;br&gt;
    status ENUM('active','revoked','expired') DEFAULT 'active',&lt;br&gt;
    activations LONGTEXT NULL,&lt;br&gt;
    max_activations TINYINT UNSIGNED DEFAULT 1,&lt;br&gt;
    expires_at DATETIME NULL,&lt;br&gt;
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,&lt;br&gt;
    PRIMARY KEY (id),&lt;br&gt;
    UNIQUE KEY license_key (license_key)&lt;br&gt;
) {$wpdb-&amp;gt;get_charset_collate()};";&lt;/p&gt;

&lt;p&gt;require_once ABSPATH . 'wp-admin/includes/upgrade.php';&lt;br&gt;
dbDelta( $sql );&lt;br&gt;
`&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;activations&lt;/code&gt; column is a JSON-encoded array of &lt;code&gt;{ domain, activated_at }&lt;/code&gt; pairs. For more than a few products this should probably be a separate table — but for most plugin authors, JSON in one column is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Generating License Keys
&lt;/h2&gt;

&lt;p&gt;Keep them short enough to be typed if needed, long enough to be unguessable. A common pattern is four blocks of uppercase hex:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;php&lt;br&gt;
function generate_license_key() {&lt;br&gt;
    $blocks = [];&lt;br&gt;
    for ( $i = 0; $i &amp;lt; 4; $i++ ) {&lt;br&gt;
        $blocks[] = strtoupper( bin2hex( random_bytes( 4 ) ) );&lt;br&gt;
    }&lt;br&gt;
    return implode( '-', $blocks );&lt;br&gt;
    // Example: 9F3A2B71-C8D4E5F6-1A2B3C4D-5E6F7081&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;random_bytes()&lt;/code&gt; is cryptographically secure. Don't use &lt;code&gt;rand()&lt;/code&gt; or &lt;code&gt;uniqid()&lt;/code&gt; for license keys — they're predictable.&lt;/p&gt;

&lt;p&gt;Hook key generation into your order completion flow. If you're using WooCommerce on the server side to sell the plugin, that's &lt;code&gt;woocommerce_order_status_completed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The REST API
&lt;/h2&gt;

&lt;p&gt;Three endpoints are enough. Register them on &lt;code&gt;rest_api_init&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;php&lt;br&gt;
add_action( 'rest_api_init', function() {&lt;br&gt;
    register_rest_route( 'license/v1', '/activate', [&lt;br&gt;
        'methods'             =&amp;gt; 'POST',&lt;br&gt;
        'callback'            =&amp;gt; 'license_activate',&lt;br&gt;
        'permission_callback' =&amp;gt; '__return_true',&lt;br&gt;
    ] );&lt;br&gt;
    register_rest_route( 'license/v1', '/deactivate', [&lt;br&gt;
        'methods'             =&amp;gt; 'POST',&lt;br&gt;
        'callback'            =&amp;gt; 'license_deactivate',&lt;br&gt;
        'permission_callback' =&amp;gt; '__return_true',&lt;br&gt;
    ] );&lt;br&gt;
    register_rest_route( 'license/v1', '/validate', [&lt;br&gt;
        'methods'             =&amp;gt; 'POST',&lt;br&gt;
        'callback'            =&amp;gt; 'license_validate',&lt;br&gt;
        'permission_callback' =&amp;gt; '__return_true',&lt;br&gt;
    ] );&lt;br&gt;
} );&lt;br&gt;
&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The activate handler is the most important one:&lt;/p&gt;

&lt;p&gt;`php&lt;br&gt;
function license_activate( WP_REST_Request $req ) {&lt;br&gt;
    $key    = sanitize_text_field( $req-&amp;gt;get_param( 'license_key' ) );&lt;br&gt;
    $domain = sanitize_text_field( $req-&amp;gt;get_param( 'domain' ) );&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if ( ! $key || ! $domain ) {
    return new WP_REST_Response( [ 'success' =&amp;gt; false, 'message' =&amp;gt; 'Missing parameters' ], 400 );
}

global $wpdb;
$table   = $wpdb-&amp;gt;prefix . 'plugin_licenses';
$license = $wpdb-&amp;gt;get_row( $wpdb-&amp;gt;prepare( "SELECT * FROM $table WHERE license_key = %s", $key ) );

if ( ! $license ) {
    return new WP_REST_Response( [ 'success' =&amp;gt; false, 'message' =&amp;gt; 'Invalid license key' ], 404 );
}
if ( 'active' !== $license-&amp;gt;status ) {
    return new WP_REST_Response( [ 'success' =&amp;gt; false, 'message' =&amp;gt; 'License is ' . $license-&amp;gt;status ], 403 );
}
if ( $license-&amp;gt;expires_at &amp;amp;&amp;amp; strtotime( $license-&amp;gt;expires_at ) &amp;lt; time() ) {
    return new WP_REST_Response( [ 'success' =&amp;gt; false, 'message' =&amp;gt; 'License expired' ], 403 );
}

$activations = json_decode( $license-&amp;gt;activations, true ) ?: [];
$existing    = array_filter( $activations, fn( $a ) =&amp;gt; $a['domain'] === $domain );

if ( ! $existing &amp;amp;&amp;amp; count( $activations ) &amp;gt;= (int) $license-&amp;gt;max_activations ) {
    return new WP_REST_Response( [ 'success' =&amp;gt; false, 'message' =&amp;gt; 'Activation limit reached' ], 403 );
}

if ( ! $existing ) {
    $activations[] = [ 'domain' =&amp;gt; $domain, 'activated_at' =&amp;gt; current_time( 'mysql' ) ];
    $wpdb-&amp;gt;update( $table, [ 'activations' =&amp;gt; wp_json_encode( $activations ) ], [ 'id' =&amp;gt; $license-&amp;gt;id ] );
}

return new WP_REST_Response( [
    'success'    =&amp;gt; true,
    'expires_at' =&amp;gt; $license-&amp;gt;expires_at,
], 200 );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
`&lt;/p&gt;

&lt;p&gt;The validate and deactivate handlers follow the same pattern: look up the license, check status, update activations as needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The Client Class
&lt;/h2&gt;

&lt;p&gt;This lives inside the commercial plugin. It's what calls the server.&lt;/p&gt;

&lt;p&gt;`php&lt;br&gt;
class Plugin_License_Client {&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const OPTION_KEY   = 'my_plugin_license_key';
const TRANSIENT    = 'my_plugin_license_valid';
const API_BASE     = 'https://yourdomain.com/wp-json/license/v1';
const CACHE_PERIOD = WEEK_IN_SECONDS;

public function is_valid() {
    $cached = get_transient( self::TRANSIENT );
    if ( false !== $cached ) {
        return (bool) $cached;
    }
    $valid = $this-&amp;gt;remote_validate();
    set_transient( self::TRANSIENT, $valid ? 1 : 0, self::CACHE_PERIOD );
    return $valid;
}

public function activate( $license_key ) {
    $response = wp_remote_post( self::API_BASE . '/activate', [
        'timeout' =&amp;gt; 15,
        'body'    =&amp;gt; [
            'license_key' =&amp;gt; $license_key,
            'domain'      =&amp;gt; $this-&amp;gt;current_domain(),
        ],
    ] );
    if ( is_wp_error( $response ) ) {
        return [ 'success' =&amp;gt; false, 'message' =&amp;gt; 'Could not reach license server' ];
    }
    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( ! empty( $body['success'] ) ) {
        update_option( self::OPTION_KEY, $license_key );
        set_transient( self::TRANSIENT, 1, self::CACHE_PERIOD );
    }
    return $body;
}

private function remote_validate() {
    $key = get_option( self::OPTION_KEY );
    if ( ! $key ) {
        return false;
    }
    $response = wp_remote_post( self::API_BASE . '/validate', [
        'timeout' =&amp;gt; 10,
        'body'    =&amp;gt; [
            'license_key' =&amp;gt; $key,
            'domain'      =&amp;gt; $this-&amp;gt;current_domain(),
        ],
    ] );
    if ( is_wp_error( $response ) ) {
        // Fail open: don't punish customers for our server being down.
        return true;
    }
    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    return ! empty( $body['success'] );
}

private function current_domain() {
    return wp_parse_url( home_url(), PHP_URL_HOST );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
`&lt;/p&gt;

&lt;p&gt;Notice the &lt;code&gt;// Fail open&lt;/code&gt; comment in &lt;code&gt;remote_validate()&lt;/code&gt;. This is the single most important design decision in the whole system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Fail Open, Not Closed
&lt;/h2&gt;

&lt;p&gt;When your license server is unreachable — and it &lt;em&gt;will&lt;/em&gt; be unreachable sometimes, because the internet is the internet — what should happen?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed:&lt;/strong&gt; the plugin disables itself until it can re-validate. Customers see a broken site. They email you. Your reputation craters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail open:&lt;/strong&gt; the plugin keeps working. A small percentage of bad actors might get a few extra hours of unlicensed use. So what.&lt;/p&gt;

&lt;p&gt;Always fail open. Your paying customers are the ones you have to protect, and they're the ones who suffer most when validation calls fail. Pirates will pirate either way.&lt;/p&gt;

&lt;p&gt;A reasonable middle ground: cache the last successful validation for a week. If the server is unreachable, keep working off the cache for up to 30 days. After 30 days of no successful validation, show a non-blocking admin notice. Never disable the plugin outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The Settings Page
&lt;/h2&gt;

&lt;p&gt;Customers need a place to enter their key. A minimal settings page does the job:&lt;/p&gt;

&lt;p&gt;`php&lt;br&gt;
add_action( 'admin_menu', function() {&lt;br&gt;
    add_options_page(&lt;br&gt;
        'My Plugin License',&lt;br&gt;
        'My Plugin License',&lt;br&gt;
        'manage_options',&lt;br&gt;
        'my-plugin-license',&lt;br&gt;
        'render_license_page'&lt;br&gt;
    );&lt;br&gt;
} );&lt;/p&gt;

&lt;p&gt;function render_license_page() {&lt;br&gt;
    $client = new Plugin_License_Client();&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if ( isset( $_POST['license_key'] ) &amp;amp;&amp;amp; check_admin_referer( 'my_plugin_license' ) ) {
    $result = $client-&amp;gt;activate( sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) );
    echo '&amp;lt;div class="notice notice-' . ( $result['success'] ? 'success' : 'error' ) . '"&amp;gt;&amp;lt;p&amp;gt;'
        . esc_html( $result['message'] ?? 'License activated.' ) . '&amp;lt;/p&amp;gt;&amp;lt;/div&amp;gt;';
}

$current = get_option( Plugin_License_Client::OPTION_KEY );
?&amp;gt;
&amp;lt;div class="wrap"&amp;gt;
    &amp;lt;h1&amp;gt;License&amp;lt;/h1&amp;gt;
    &amp;lt;form method="post"&amp;gt;
        &amp;lt;?php wp_nonce_field( 'my_plugin_license' ); ?&amp;gt;
        &amp;lt;input type="text" name="license_key" value="&amp;lt;?php echo esc_attr( $current ); ?&amp;gt;" class="regular-text" /&amp;gt;
        &amp;lt;?php submit_button( 'Activate License' ); ?&amp;gt;
    &amp;lt;/form&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;?php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
`&lt;/p&gt;

&lt;p&gt;In production you'll want better UX — show the activation status, the bound domain, a deactivate button. But this is the minimal viable settings page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes to Avoid
&lt;/h2&gt;

&lt;p&gt;A few things I've seen go wrong, both in our system and in others:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validating on every page load.&lt;/strong&gt; Kills performance, multiplies failure modes. Cache it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoding the license server URL with &lt;code&gt;http://&lt;/code&gt;.&lt;/strong&gt; Use HTTPS. Always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storing license keys in plain options without sanitization.&lt;/strong&gt; Treat them like passwords on input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not handling the staging/dev case.&lt;/strong&gt; Customers will test on &lt;code&gt;staging.theirsite.com&lt;/code&gt;. Decide whether you grant a free extra activation for &lt;code&gt;*.dev&lt;/code&gt;, &lt;code&gt;*.local&lt;/code&gt;, &lt;code&gt;staging.*&lt;/code&gt;, or whether you fail and document the workaround. Either is fine; just decide before you ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No way for customers to deactivate themselves.&lt;/strong&gt; They will switch domains. They will rebuild sites. Give them a self-service deactivate button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging full license keys server-side.&lt;/strong&gt; If your server is ever compromised, those logs become a treasure map. Log the last 4 characters only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;This system covers everything except update delivery — letting valid license holders pull plugin updates from your server instead of WordPress.org. That's its own write-up because it involves WordPress's &lt;code&gt;pre_set_site_transient_update_plugins&lt;/code&gt; filter and serving zipped plugin packages from your server. I'll cover it in a follow-up.&lt;/p&gt;

&lt;p&gt;If you want to see this system running in production, it's what powers licensing for &lt;a href="https://ridecabwp.com" rel="noopener noreferrer"&gt;RideCab WP&lt;/a&gt;, our commercial WooCommerce taxi booking plugin. Same architecture as above, with a few extra layers for domain wildcards and team licenses.&lt;/p&gt;

&lt;p&gt;Questions on any part of this? Drop them in the comments — happy to go deeper on the API design, the caching strategy, or the failure modes.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Taxi Booking Plugin for WooCommerce — Lessons from the Trenches</title>
      <dc:creator>Canopus agency</dc:creator>
      <pubDate>Wed, 17 Jun 2026 09:50:03 +0000</pubDate>
      <link>https://dev.to/canopuswebagency/building-a-taxi-booking-plugin-for-woocommerce-lessons-from-the-trenches-m75</link>
      <guid>https://dev.to/canopuswebagency/building-a-taxi-booking-plugin-for-woocommerce-lessons-from-the-trenches-m75</guid>
      <description>&lt;p&gt;When we started building &lt;strong&gt;RideCab WP&lt;/strong&gt;, a &lt;a href="https://ridecabwp.com/" rel="noopener noreferrer"&gt;WooCommerce-based taxi booking plugin&lt;/a&gt;, we underestimated almost everything. Booking flows look simple from the outside: pick up here, drop off there, pay. Under the hood, it's a maze of edge cases.&lt;/p&gt;

&lt;p&gt;This post is a brain dump of the technical decisions, dead ends, and lessons from building a commercial WordPress plugin for a real business niche: taxi and fleet operators who want to take bookings from their own website without paying SaaS fees or per-ride commissions forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why WordPress + WooCommerce?
&lt;/h2&gt;

&lt;p&gt;The taxi industry is full of expensive SaaS platforms that charge per booking and lock operators into someone else's ecosystem. Most small fleet owners already have a website — usually WordPress — and don't want a separate dashboard, separate billing, and a 15% cut on every ride.&lt;/p&gt;

&lt;p&gt;WordPress + WooCommerce gives us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing checkout and payment gateway infrastructure&lt;/li&gt;
&lt;li&gt;Order management UI that fleet owners already understand&lt;/li&gt;
&lt;li&gt;A massive plugin and theme ecosystem&lt;/li&gt;
&lt;li&gt;Zero recurring fees for the operator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff: WooCommerce's data model is built for physical and digital products, not service bookings with dynamic pricing. Bending it to fit took real work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Product Type vs. Custom Post Type
&lt;/h2&gt;

&lt;p&gt;The first big architectural decision: should a "ride booking" be a WooCommerce product or a separate entity?&lt;/p&gt;

&lt;p&gt;We tried both. A pure custom post type gave us full control but meant rebuilding cart, checkout, and payment integration from scratch. A pure custom WooCommerce product type kept us inside WooCommerce's flow but fought us on every dynamic pricing rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The answer was a hybrid:&lt;/strong&gt; a custom WooCommerce product type (&lt;code&gt;ridecab_booking&lt;/code&gt;) that delegates pricing to a separate fare engine. Each booking becomes a real WooCommerce order, but the price is calculated server-side at add-to-cart time from distance, zones, and vehicle type — not stored as a fixed product price.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;php&lt;br&gt;
add_filter( 'woocommerce_product_class', function( $classname, $product_type ) {&lt;br&gt;
    if ( 'ridecab_booking' === $product_type ) {&lt;br&gt;
        $classname = 'WC_Product_RideCab_Booking';&lt;br&gt;
    }&lt;br&gt;
    return $classname;&lt;br&gt;
}, 10, 2 );&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This let us reuse WooCommerce's tax calculation, coupon system, and email notifications for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fare Calculation Problem
&lt;/h2&gt;

&lt;p&gt;This is where 70% of the complexity lives.&lt;/p&gt;

&lt;p&gt;A real taxi fare isn't just &lt;code&gt;distance × rate&lt;/code&gt;. You're dealing with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Base fare + per-km rate + per-minute rate&lt;/li&gt;
&lt;li&gt;Zone-based pricing (airport surcharges, city center premiums)&lt;/li&gt;
&lt;li&gt;Vehicle class multipliers (standard, premium, van)&lt;/li&gt;
&lt;li&gt;Time-of-day modifiers (night rates, weekend rates)&lt;/li&gt;
&lt;li&gt;Minimum fares&lt;/li&gt;
&lt;li&gt;Round-trip discounts&lt;/li&gt;
&lt;li&gt;Distance calculation between two real-world points&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We tried Haversine distance (straight-line) first. It was useless — drivers don't fly. Real fares need &lt;em&gt;driving distance&lt;/em&gt;, which means a routing API. We integrated Google Distance Matrix as the default and made the integration pluggable so operators can swap in OSRM, Mapbox, or other providers.&lt;/p&gt;

&lt;p&gt;The fare engine itself ended up as a chain of modifiers — each rule (base, distance, time, vehicle, zone) is applied in sequence, and each can be enabled, disabled, or overridden per operator. Keeping each rule as a small, independent class made testing far easier than the giant if/else block we started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Bookings as WooCommerce Orders
&lt;/h2&gt;

&lt;p&gt;A booking has data WooCommerce doesn't natively understand: pickup location, drop-off location, scheduled time, vehicle, passenger count, flight number, special notes.&lt;/p&gt;

&lt;p&gt;We attach all of this as order item meta:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;php&lt;br&gt;
add_action( 'woocommerce_add_order_item_meta', function( $item_id, $values ) {&lt;br&gt;
    if ( isset( $values['ridecab_pickup'] ) ) {&lt;br&gt;
        wc_add_order_item_meta( $item_id, '_pickup_location', $values['ridecab_pickup'] );&lt;br&gt;
        wc_add_order_item_meta( $item_id, '_dropoff_location', $values['ridecab_dropoff'] );&lt;br&gt;
        wc_add_order_item_meta( $item_id, '_scheduled_time', $values['ridecab_time'] );&lt;br&gt;
    }&lt;br&gt;
}, 10, 2 );&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Then we filter the order admin screen and the customer email to surface this data clearly. A fleet dispatcher needs to see "Pickup: 14 Avenue Habib Bourguiba at 9:30 AM" the moment they open the order, not buried under a list of meta fields.&lt;/p&gt;

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

&lt;p&gt;A commercial plugin needs license keys, domain locking, activation/deactivation, and update delivery. Most developers reach for Freemius. We chose to build our own, partly for control, partly to avoid the revenue share.&lt;/p&gt;

&lt;p&gt;The architecture is two pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;server-side mu-plugin&lt;/strong&gt; that generates and validates license keys against an admin dashboard, with domain binding.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;client-side class&lt;/strong&gt; inside the plugin that calls home on activation, caches the response, and re-validates quietly in the background.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key insight: don't validate the license on every page load. That kills performance and creates downtime if your license server has a hiccup. Validate on activation, cache the result for several days, and re-validate quietly in the background.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;php&lt;br&gt;
class RideCab_License {&lt;br&gt;
    public function is_valid() {&lt;br&gt;
        $cached = get_transient( 'ridecab_license_valid' );&lt;br&gt;
        if ( false !== $cached ) {&lt;br&gt;
            return (bool) $cached;&lt;br&gt;
        }&lt;br&gt;
        $valid = $this-&amp;gt;remote_validate();&lt;br&gt;
        set_transient( 'ridecab_license_valid', $valid ? 1 : 0, WEEK_IN_SECONDS );&lt;br&gt;
        return $valid;&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This pattern — cache aggressively, fail gracefully, never block the admin UI — is the difference between a licensing system users tolerate and one they hate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We'd Do Differently
&lt;/h2&gt;

&lt;p&gt;A few honest reflections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We over-engineered the fare engine early.&lt;/strong&gt; A simple distance-based fare would have covered 80% of customers on day one. We built for the 20% edge cases before the 80% was solid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We underestimated documentation.&lt;/strong&gt; A commercial plugin lives or dies on its docs. We treat docs as code now: versioned, reviewed, shipped with releases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We should have set up update delivery from day one.&lt;/strong&gt; Pushing updates manually for the first few customers was painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test coverage on the fare engine paid for itself ten times over.&lt;/strong&gt; Pricing bugs are the kind users notice immediately and remember forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;RideCab WP is now in production with real fleet operators running their booking sites on it. You can see the result at &lt;a href="https://ridecabwp.com" rel="noopener noreferrer"&gt;https://ridecabwp.com&lt;/a&gt; — one-time license, no commission, no per-booking fees.&lt;/p&gt;

&lt;p&gt;If you're building commercial WordPress plugins, the meta-lesson is this: WooCommerce is a powerful foundation, but you'll spend more time bending it than extending it. Plan for that, lean on its hooks instead of fighting its data model, and ship the 80% before you chase the 20%.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments — especially on the fare engine architecture or the licensing approach. The licensing system in particular is its own rabbit hole; I'll write that one up next.&lt;/p&gt;

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