<?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>Web, Mobile and SEO: why small businesses should stop treating them as three separate projects</title>
      <dc:creator>Canopus agency</dc:creator>
      <pubDate>Wed, 28 Oct 2026 09:43:40 +0000</pubDate>
      <link>https://dev.to/canopuswebagency/web-mobile-and-seo-why-small-businesses-should-stop-treating-them-as-three-separate-projects-33nd</link>
      <guid>https://dev.to/canopuswebagency/web-mobile-and-seo-why-small-businesses-should-stop-treating-them-as-three-separate-projects-33nd</guid>
      <description>&lt;p&gt;Most small businesses I work with come to me with one of three requests. "We need a new website." "We need an app." "We need to rank on Google."&lt;/p&gt;

&lt;p&gt;They almost always treat these as separate jobs, hired out to separate people, at separate times. And that's usually where the money gets wasted.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://canopuswebagency.com" rel="noopener noreferrer"&gt;Canopus Web Agency&lt;/a&gt;, a small studio doing web development, mobile apps and SEO for SMBs, mostly in France and Belgium. After a few years of building sites for taxi companies, towing services, local shops and SaaS founders, here's what I've learned about why these three things belong in the same conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The website is the foundation, not the brochure
&lt;/h2&gt;

&lt;p&gt;A lot of sites are still built like printed flyers: nice homepage, an "about" page, a contact form, done.&lt;/p&gt;

&lt;p&gt;The problem is that Google doesn't rank flyers. It ranks pages that answer specific questions. A taxi company in Bordeaux doesn't need one "Services" page, it needs a page for airport transfers, one for the train station, one for long distance trips, each one with real content and real prices.&lt;/p&gt;

&lt;p&gt;So before anyone writes a line of CSS, the page structure should already be planned around what people search for. If you build the site first and "add SEO later", you end up rebuilding half of it.&lt;/p&gt;

&lt;p&gt;What I check before starting a build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which searches actually bring customers (not just traffic)&lt;/li&gt;
&lt;li&gt;One page per intent, no "everything" pages&lt;/li&gt;
&lt;li&gt;Clean URLs that won't need to change in a year&lt;/li&gt;
&lt;li&gt;A CMS the client can actually update without calling me&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Performance is an SEO feature and a sales feature
&lt;/h2&gt;

&lt;p&gt;On mobile, a slow site loses people before they see anything. Core Web Vitals are part of how Google judges a page, but honestly the bigger reason to care is conversion. Someone booking a ride at 6am from their phone will not wait 5 seconds for a slider to load.&lt;/p&gt;

&lt;p&gt;A few things that make the biggest difference on the projects I do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drop the heavy page builder effects nobody asked for&lt;/li&gt;
&lt;li&gt;Serve images in WebP/AVIF with proper sizes&lt;/li&gt;
&lt;li&gt;Load third-party scripts (chat widgets, trackers) late or not at all&lt;/li&gt;
&lt;li&gt;Cache properly, and test on a real cheap Android phone, not just your laptop&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Do you actually need a mobile app?
&lt;/h2&gt;

&lt;p&gt;Sometimes yes. Often no.&lt;/p&gt;

&lt;p&gt;If your customers use your service once a year, a fast mobile website will do the job better than an app they have to install. If they use it every week (drivers, delivery staff, repeat clients, internal teams), an app starts to make sense: push notifications, offline mode, saved data, faster repeat actions.&lt;/p&gt;

&lt;p&gt;When an app does make sense, I usually build it with React Native / Expo so it ships on Android and iOS from one codebase, and it talks to the same backend as the website. One source of truth for bookings, users and content. That way the site and the app never disagree with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SEO is not a one-time task
&lt;/h2&gt;

&lt;p&gt;The sites that rank well in my experience share a few boring habits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They publish useful content regularly, written for their actual customers&lt;/li&gt;
&lt;li&gt;Their Google Business Profile is complete and gets reviews&lt;/li&gt;
&lt;li&gt;Their name, address and phone are the same everywhere online&lt;/li&gt;
&lt;li&gt;They fix technical issues as they appear instead of once every 3 years&lt;/li&gt;
&lt;li&gt;They have structured data (LocalBusiness, FAQ, Service) set up properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is magic. It just needs someone to keep doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Put it together
&lt;/h2&gt;

&lt;p&gt;Here's how I'd plan it for a small business starting from scratch:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keyword and competitor research first, so you know what pages to build&lt;/li&gt;
&lt;li&gt;A fast, well-structured website built on that plan&lt;/li&gt;
&lt;li&gt;Local SEO setup (Google Business Profile, citations, schema)&lt;/li&gt;
&lt;li&gt;Content added month after month&lt;/li&gt;
&lt;li&gt;A mobile app only when usage data shows people would come back to it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Doing it in this order costs less than doing it backwards, and each step makes the next one easier.&lt;/p&gt;




&lt;p&gt;If you're a developer, I'd be curious how you handle the SEO side with your own clients. Do you plan it before the build or bring someone in after?&lt;/p&gt;

&lt;p&gt;And if you run a business and want help with any of this, you can find my work at &lt;a href="https://canopuswebagency.com" rel="noopener noreferrer"&gt;canopuswebagency.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>mobile</category>
      <category>wordpress</category>
    </item>
    <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>
