DomainPunch Logo

 A Service of Softnik Technologies

Native Development with Direct Database Access

A developer guide for building server-side tools for Watch My Domains SED using the application's own internal classes and direct database access.

This guide is intended for development teams within an organization that manage their own WMD SED installation and need to build internal tools with native, direct access to the application database.

It assumes the team has full administrative control of the server, understands the security implications of direct database access, and is building tools for internal operational use - not for end users or external integrations. If you are building tools for external use, or want to work within WMD's user and permission model, use the REST API instead.

Security Warning

Direct-access tools bypass all application-level permission controls and operate with unrestricted access to the WMD database.

This is equivalent to full administrative access to your domain data.

This is fundamentally different from tools built against the REST API, which operate within WMD's session, user, and column-level permission model. A direct-access tool loaded via the bootstrap has the same database privileges as the application itself - it can read, write, and delete any data regardless of which WMD user invokes it.

Before building or deploying a direct-access tool, understand the following:

  • Server access implies database access. Anyone with the ability to place files on the server already has access to the database credentials stored in WMD's configuration files. Direct-access tools do not introduce a new attack surface - but they do make that access easier to exercise.
  • In browser mode, WMD's login session is the only access control. If a user is logged in to WMD, they can invoke any direct-access tool placed in user-tools/ regardless of their WMD role or permissions. There is no automatic role check.
  • In CLI mode, there is no access control at all. Any OS user with shell access and read permission on the tool file can run it.
  • Accidental exposure is common. A tool placed in user-tools/ may be reachable via URL without the developer realising it. If browser access is not explicitly disabled, the tool is reachable over HTTP.
  • These tools should only be built and deployed by administrators who have full server access and understand the implications. They must never be placed in a location accessible to untrusted users.
  • WMD user access rights are completely ignored. WMD's permission model - role assignments, category restrictions, column-level access controls - exists entirely within the application layer. Direct database access operates below that layer. A read-only WMD user with access to a browser-accessible tool has exactly the same database access as a full administrator.
  • Write operations are permanent. updateTable() and delete operations have no undo. Errors in tool logic can corrupt or destroy data across the entire domain portfolio. Bulk operations can affect thousands of records in a single run with no confirmation step.
  • If exposed without session validation, the tool becomes a public database endpoint. Anyone who can access the URL - including unauthenticated users, bots, or scanners - can execute read, write, and delete operations across your entire domain database.
  • Treat these tools with the same care as direct database access. Because that is exactly what they are.

Table of Contents


Overview

WMD SED exposes two ways to interact with it programmatically:

This guide covers the second approach: direct database access via internal classes.


Two Approaches to Extending WMD SED

API-Based Tools

Use the REST API when:

Direct Access Tools

Use direct database access when:


Where to Place Your Tool

Place all custom tools in the user-tools/ folder inside your WMD SED installation root:

wmdsed/
  user-tools/
    my-tool.php
    another-tool.php

Do not place tools in wmdsed/tools/ - that folder is reserved for tools distributed with the application and may be overwritten during updates.

The user-tools/ folder keeps your custom code clearly separated and safe across upgrades.


Bootstrapping: The Loader

Every direct-access tool must start by loading WMD's bootstrap file. The loader initialises the WMD framework, sets up autoloading for all internal classes, starts the session (for browser mode), and connects to the database configuration. Nothing else is needed to get access to the full WMD class library.

Hardcoded path (simple, single-depth tools)

If your tool always lives in user-tools/ directly under the WMD root, the path is predictable:

require '../lib/php/loader.php';

Portable path - findLoader() (recommended)

If your tool may be placed at different depths within the WMD directory tree - for example user-tools/wcag/, tools/my-app/wcag/, or any subfolder - hardcoding the relative path will break when the depth changes. Use a tree-walking helper instead:

function findLoader(): string {
    $dir = __DIR__;
    for ($i = 0; $i < 6; $i++) {
        $candidate = $dir . '/lib/php/loader.php';
        if (file_exists($candidate)) return $candidate;
        $dir = dirname($dir);
    }
    die("Could not locate loader.php  -  ensure this tool is installed within the WMD SED directory tree.
");
}
require findLoader();

This uses __DIR__ - always the directory of the current file, regardless of where PHP is invoked from - and walks up the tree until it finds lib/php/loader.php. The limit of 6 levels prevents an infinite walk to the filesystem root. If the WMD root cannot be found, the script exits immediately with a clear error rather than failing silently with a missing-file warning.

Use findLoader() for any tool intended to be portable across installations or folder structures. The hardcoded relative path is fine only when the tool will always sit at a fixed, known depth from the WMD root.


Dual Mode: CLI and Browser

WMD tools can run in both CLI and browser contexts from the same file. The key utility method is:

UTIL::is_cli()

Use this to branch behaviour where the two modes differ.

CLI only by default. Avoid browser mode.

Direct-access tools are designed for CLI execution - cron jobs, scheduled tasks, and administrative scripts run by someone with full server access. Browser mode is technically possible but carries serious risk: any authenticated WMD user who can reach the URL can trigger unrestricted database operations, regardless of their role or permissions within WMD.

Consequences of uncontrolled browser access include:

  • Any authenticated WMD user can read every domain record in the portfolio - including columns and categories their WMD account has no permission to see
  • Any authenticated WMD user can trigger write or delete operations across the entire domain table - WMD's column-level and category-level access controls are completely bypassed
  • Data can be corrupted or permanently deleted with no undo - there is no audit trail specific to direct-access tool operations
  • Bulk operations can affect thousands of records in a single run with no confirmation step

User access rights are completely ignored. WMD's permission model - role assignments, category restrictions, column-level access - exists entirely within the application layer. Direct database access operates below that layer entirely. A read-only WMD user hitting this tool in a browser has the same database access as a full administrator.

The recommended practice is to exit immediately if not running from CLI:

if (!UTIL::is_cli()) {
    UTIL::print("This tool can only be run from the command line.");
    exit;
}

Put this check at the very top of your tool, before any other logic. This one guard eliminates the entire browser attack surface. If you find yourself wanting browser access to a direct-access tool, consider whether the task should instead be built against the REST API, which operates within WMD's proper permission model.

Only bypass this guard if you have a specific, well-understood operational reason to allow browser execution - and you fully accept the security implications documented in the Security Warning above.

CLI Mode

When run from the command line, parse arguments before doing anything else:

if (UTIL::is_cli()) {
    UTIL::parse_request_data_in_cli();
}
$rd = UTIL::get_unsafe_request_data_array();

parse_request_data_in_cli() maps --key=value style arguments into the request data array, making them available via $rd in the same way $_GET / $_POST would be in browser mode.

Important: bare flags without a value (e.g. --debug, --dry-run) are not populated into $rd by parse_request_data_in_cli(). Detect these by scanning $argv directly:

if (UTIL::is_cli()) {
    foreach (($GLOBALS['argv'] ?? []) as $arg) {
        $argLower = strtolower(ltrim($arg, '-'));
        if ($argLower === 'debug' || strpos($argLower, 'debug=') === 0) {
            $debug = true;
        }
    }
}

Browser Mode

In browser mode the loader starts the WMD session, but the tool must still explicitly validate that session before doing anything that touches the database. Browser access should only be allowed when you have a specific, well-understood operational reason to permit it.

Query string parameters are available via $rd after calling UTIL::get_unsafe_request_data_array().

Output

UTIL::print() handles output in both modes natively. In CLI it writes to stdout. In browser it wraps content appropriately for HTML display. Use it for all output rather than echo directly:

UTIL::print("Processing complete.");

Authentication and Database Access

All direct-access tools obtain the database handle through the Auth class, which reads WMD's own database configuration. You never need to hardcode credentials:

// Use READONLY_TABLES or, in newer versions, the alias STANDARD_INIT
$auth = new \CodePunch\Config\Auth(\CodePunch\DB\Database::READONLY_TABLES);
if (!$auth) {
    throw new Exception("Failed to initialise Auth  -  check database configuration.");
}

Session Validation in Browser Mode

In CLI mode there is no session to check. In browser mode however, the tool must explicitly verify that the user is logged in before doing anything else. The loader does not enforce this automatically - without the check, an unauthenticated request will reach the database.

Browser access to direct-access tools is strongly discouraged. Any user with a valid WMD login session can invoke the tool regardless of their role or permissions within WMD. If you do not have a specific, well-understood reason to expose the tool via a browser, disable browser access entirely by exiting immediately when not in CLI mode:

if (!UTIL::is_cli()) {
    UTIL::print("This tool can only be run from the command line.");
    exit;
}

If you allow browser access and forget to call validateSession(false, false), the tool will execute with full database access for any HTTP request - authenticated or not. There is no fallback protection. An unauthenticated visitor, a search engine crawler, or anyone who discovers the URL will have unrestricted read and write access to your entire domain database. This is not a theoretical risk - it will happen silently, with no error and no log entry in WMD.

If you choose to allow browser access: treating session validation as optional or something to add later is not acceptable. It must be the first thing that runs after Auth is instantiated, every single time, with no exceptions.

If you choose to allow browser access and fully understand what you are doing, validate the session before obtaining the database handle. Call validateSession(false, false) - the two false arguments are critical:

On failure, redirect to the login page manually rather than letting Auth handle it. After confirming the session is valid, also verify the user is an administrator - non-admin users should be rejected even with a valid session:

if (!UTIL::is_cli()) {
    // RECOMMENDED: disable browser access entirely instead:
    // UTIL::print("This tool can only be run from the command line.");
    // exit;

    $sessionStatus = $auth->validateSession(false, false);
    if ($sessionStatus !== \CodePunch\Config\Auth::VALID) {
        header("Location: ../login.php");
        exit;
    }

    if (!\CodePunch\Config\Auth::isAdmin()) {
        UTIL::print("Access denied  -  this tool requires administrator privileges.");
        exit;
    }
}

Auth::isAdmin() is a static method that reads from the session after it has been validated - no additional arguments needed. It returns false for any non-admin user, including users with valid sessions. Always place the admin check after validateSession(), never before.

validateSession(false, false) returns one of the following constants defined on the Auth class:

ConstantValueMeaning
Auth::VALID0Session is active and authenticated - proceed
Auth::TIMEDOUT1Session has expired
Auth::INVALID-1Session is not authenticated
Auth::UNKNOWN-2Session state cannot be determined
Auth::ERROR-3An error occurred during validation

Only Auth::VALID should be allowed to proceed. Any other status redirects to login.

After session validation, obtain the database handle as normal:

$db = $auth->getDatabase();
if (!$db) {
    throw new Exception("Failed to obtain a database handle  -  check connection settings.");
}

About READONLY_TABLES and STANDARD_INIT

Database::READONLY_TABLES does not mean the connection is restricted to read-only operations. The constant controls whether the database initialisation process runs self-diagnostics, integrity checks, and repair routines on startup. Passing READONLY_TABLES skips those routines, which is the correct choice for tools - there is no need to run diagnostics on every tool invocation.

A tool initialised with READONLY_TABLES can still call updateTable(), insertIntoTable(), and insertColumnsAndKeys() without any restriction.

Because the name READONLY_TABLES is misleading, newer versions of WMD SED also define a clearer alias:

const STANDARD_INIT = self::READONLY_TABLES;

STANDARD_INIT and READONLY_TABLES are identical. Use STANDARD_INIT in new tools written for newer versions. Use READONLY_TABLES if your tool needs to be compatible with older versions.

Do not attempt to use any other constant such as ALL_TABLES - no such constant exists and it will produce a fatal error.

// Preferred in newer versions
$auth = new \CodePunch\Config\Auth(\CodePunch\DB\Database::STANDARD_INIT);

// Equivalent - use this if targeting older versions
$auth = new \CodePunch\Config\Auth(\CodePunch\DB\Database::READONLY_TABLES);

// Wrong - ALL_TABLES does not exist, this will throw a fatal error
$auth = new \CodePunch\Config\Auth(\CodePunch\DB\Database::ALL_TABLES);

Getting the Domain Table Name

Never hardcode the domain table name. Always retrieve it via:

$table = $db->getDomainTableName();

This returns the correct table name for your installation (typically wmdsed_domains but may differ).

Available Table Name Methods

Never hardcode any table name. Always use the appropriate method:

MethodReturns
$db->getDomainTableName()Domain table (typically wmdsed_domains)
$db->getCategoryTableName()Category definitions table
$db->getCategoryConnectionTableName()Category-to-domain mapping table
$db->getDatacolumnsTableName()Custom column registry table
$db->getSubdomainTableName()Subdomain table
$db->getLookupQueueTableName()Lookup queue table
$db->getDataHistoryTableName()Data history table

Querying the Domain Table

The primary method for reading domain data is getFromTable():

$rows = $db->getFromTable(
    $columns,    // comma-separated column names string
    $table,      // from getDomainTableName()
    $where,      // WHERE clause with ? placeholders
    $params,     // array of bound parameter values
    $orderBy,    // sort column name
    $direction,  // 'asc' or 'desc'
    $limit       // maximum rows to return
);

This method uses PDO prepared statements internally. Always use ? placeholders in your WHERE clause and pass values in the $params array - never interpolate user input directly into SQL.

The return value is an array of associative arrays, each keyed by column name exactly as specified in $columns. Returns false on query failure - always check for this:

if ($rows === false) {
    throw new Exception("Database query failed.");
}

An empty result set returns an empty array [], not false.

Fetching All Rows

To fetch all rows without a filter, pass '1' as the WHERE clause with an empty params array. Do not pass an empty string - getFromTable() may return false for an empty WHERE clause:

// Correct  -  fetch all rows
$rows = $db->getFromTable("sid, domain, availability", $table, '1', [], 'domain', 'asc', 100000);

// Wrong  -  may return false
$rows = $db->getFromTable("sid, domain, availability", $table, '', [], 'domain', 'asc', 100000);

Domain Eligibility Filtering

To exclude domains that are clearly unregistered - available with no expiry dates on record - add the following condition to your WHERE clause:

$where[]  = "NOT (availability = ? AND (registry_expiry IS NULL OR registry_expiry = '') AND (registrar_expiry IS NULL OR registrar_expiry = ''))";
$params[] = 'available';

Known values for the availability column: Not Available, available, possibly available. Domains with availability = 'Not Available' are registered and actively held. Domains with availability = 'available' and no expiry dates are either expired or were never registered.

Common Domain Table Columns

ColumnDescription
sidInternal domain ID (primary key)
domainDomain name
registry_expiryExpiry date from registry WHOIS
registrar_expiryExpiry date from registrar WHOIS
statusEPP status codes (comma-separated string)
availabilityDomain availability status e.g. Not Available, available, possibly available
primary_whois_checked_atTimestamp of last registry WHOIS lookup
secondary_whois_checked_atTimestamp of last registrar WHOIS lookup

Custom columns defined in your WMD installation are also queryable by their column name. Examples may include client_email, customer_name, or any other fields added via the custom columns feature. The domain table does not have a category column - see Querying by Category below.

Writing Data Back

To update domain records use updateTable():

$result = $db->updateTable(
    $table,               // table name
    $dataArray,           // associative array of column => value to update
    "sid=?",              // WHERE clause
    [$sid]                // bound parameter values
);
if ($result === false) {
    throw new Exception("Update failed for sid {$sid}.");
}

Querying by Category

The domain table has no category column. Categories are stored in separate tables and linked to domains through a connection table. To find domains belonging to a category, resolve through three steps.

Do not attempt to filter by category using a category column on the domain table - that column does not exist. The following pattern is wrong and will fail:

// WRONG  -  category is not a column on the domain table
$rows = $db->getFromTable("sid, domain", $table, "category = ?", ['My Category']);

Step 1 - Resolve the category name to a cid

$cid = $db->findOneOf($db->getCategoryTableName(), "name", $categoryName, "cid");
if (!$cid) {
    throw new Exception("Category not found: \"{$categoryName}\"");
}

findOneOf() searches the category table for a row where the name column matches $categoryName and returns the value of the cid column. Returns null or falsy if not found. Category names are case-sensitive.

Step 2 - Get domain IDs from the connection table

$connRows = $db->getFromTable(
    "did",
    $db->getCategoryConnectionTableName(),
    "cid = ?",
    [(int)$cid],
    '', '', 100000
);
if ($connRows === false) throw new Exception("Category connection query failed.");
if (empty($connRows)) {
    // Category exists but has no domains assigned
    exit;
}
$sids = array_map(fn($r) => (int)$r['did'], $connRows);

The category connection table links categories to domains. Each row has a cid (category ID) and a did (domain ID). The did value is the same as the sid primary key in the domain table.

Step 3 - Query the domain table using those SIDs

When passing an array of IDs as a query parameter, use Doctrine's PARAM_INT_ARRAY type. This must be wrapped as a two-element array pairing the value with the type constant - it is not a plain scalar param:

$rows = $db->getFromTable(
    "sid, domain, availability",
    $db->getDomainTableName(),
    "sid IN (?)",
    [[$sids, \Doctrine\DBAL\Connection::PARAM_INT_ARRAY]],
    'domain', 'asc', 50000
);
if ($rows === false) throw new Exception("Domain query failed.");

Complete pattern

// 1. Resolve category name to cid
$cid = $db->findOneOf($db->getCategoryTableName(), "name", "My Category", "cid");
if (!$cid) throw new Exception("Category not found.");

// 2. Get all domain SIDs in that category
$connRows = $db->getFromTable("did", $db->getCategoryConnectionTableName(), "cid = ?", [(int)$cid], '', '', 100000);
if ($connRows === false) throw new Exception("Connection table query failed.");
if (empty($connRows)) { /* no domains */ exit; }
$sids = array_map(fn($r) => (int)$r['did'], $connRows);

// 3. Fetch those domains from the domain table
$rows = $db->getFromTable(
    "sid, domain, availability, registry_expiry",
    $db->getDomainTableName(),
    "sid IN (?)",
    [[$sids, \Doctrine\DBAL\Connection::PARAM_INT_ARRAY]],
    'domain', 'asc', 50000
);
if ($rows === false) throw new Exception("Domain query failed.");

Array Parameters in getFromTable()

For scalar values, pass a plain PHP value as the param:

// Scalar param  -  plain value
$rows = $db->getFromTable("sid, domain", $table, "availability = ?", ['available']);

For array-typed parameters (e.g. IN (?)), the param must be a two-element array pairing the value with its Doctrine type constant:

// Array param  -  [value, type] pair
$sids = [101, 205, 388];
$rows = $db->getFromTable(
    "sid, domain",
    $table,
    "sid IN (?)",
    [[$sids, \Doctrine\DBAL\Connection::PARAM_INT_ARRAY]]
);

For string arrays, use \Doctrine\DBAL\Connection::PARAM_STR_ARRAY instead.

Mixed scalar and array params in the same query:

$rows = $db->getFromTable(
    "sid, domain",
    $table,
    "sid IN (?) AND availability = ?",
    [
        [$sids, \Doctrine\DBAL\Connection::PARAM_INT_ARRAY],  // array param
        'available',                                           // scalar param
    ]
);

Never pass a flat PHP array as a scalar param for IN (?). It will cause an "Array to string conversion" warning and the query will fail silently or produce incorrect results. Each element of $params is either a plain scalar or a [value, type] pair - never a raw array.


Creating Custom Domain Columns

Custom columns require two operations: adding the physical database column, then registering it in WMD's datacolumns metadata table. Both must succeed.

Column types and their rules

Typelength requiredgridview recommendation
stringYes1 - shown in domain grid
textNo0 - too large for grid
integerNo1
datetimeNo1

Validation before creating

$fname  = strtolower(UTIL::strip_punctuation($originalName, true)); // DB column name
$flabel = $originalName; // preserve original casing for display label

// Reject if it matches a built-in column
$dcolumns = $db->getDetailsOfAllDomainColumns();
if (isset($dcolumns[$fname]['custom']) && $dcolumns[$fname]['custom'] == 0) {
    throw new Exception("A built-in column named '$fname' already exists.");
}

// Reject reserved SQL keywords
if ($db->connection->getDatabasePlatform()->getReservedKeywordsList()->isKeyword($fname)) {
    throw new Exception("'$fname' is a reserved SQL keyword.");
}

// Reject if already registered as a custom column
$existing = $db->getFromTable("id", $db->getDatacolumnsTableName(), "name=? AND tablename=?",
    [$fname, \CodePunch\DB\DomainDB::DOMAIN_TABLE]);
if ($existing !== false && isset($existing[0])) {
    throw new Exception("Column '$fname' already exists.");
}

Step 1 - Add the physical database column

$columninfo = [
    'name'    => $fname,
    'type'    => 'string',   // 'string', 'text', 'integer', or 'datetime'
    'options' => [
        'notnull' => false,
        'length'  => 100,    // required for 'string' only  -  omit for text/integer/datetime
    ]
];
$keys = []; // or ['index' => $fname] or ['unique' => $fname]

$istatus = $db->insertColumnsAndKeys($db->getDomainTableName(), [$columninfo], $keys);
if (!$istatus) throw new Exception("Failed to add column '$fname' to domain table.");

Step 2 - Register in the datacolumns metadata table

$datacolumn = [
    'name'      => $fname,
    'tablename' => \CodePunch\DB\DomainDB::DOMAIN_TABLE,
    'server'    => '*',
    'label'     => $flabel,
    'editable'  => 1,
    'gridview'  => 1,    // set to 0 for 'text' columns  -  too large for the domain grid
    'width'     => 100,
    'fieldtype' => 'string',
    'custom'    => 1,
];
if ($db->insertIntoTable($db->getDatacolumnsTableName(), $datacolumn) === false) {
    throw new Exception("Column added to DB but failed to register in datacolumns table.");
}

AUDIT::add($db, \CodePunch\DB\Audit::ADD_CUSTOM_COLUMN, "Added $fname", "");

Key points:

Writing to custom columns

Use updateTable() exactly as you would for any built-in column:

$db->updateTable($table, [
    'wcag_status'     => 'pass',
    'wcag_scanned_at' => date('Y-m-d H:i:s'),
], "sid=?", [$sid]);

For datetime columns, pass a standard MySQL datetime string: date('Y-m-d H:i:s').


Handling CLI Flags and Browser Parameters

Design tools to accept the same parameters in both modes:

ModeKey=value parameterBare flag
CLI--days=60 → available in $rd['days']--debug → must scan $argv
Browser?days=60 → available in $rd['days']?debug=1 → available in $rd['debug']

A robust pattern that handles both:

$days  = 30;   // default
$debug = false;

// key=value  -  works identically in both modes via $rd
if (isset($rd['days'])) {
    $parsed = (int) $rd['days'];
    if ($parsed > 0) $days = $parsed;
}

// bare flags  -  CLI needs $argv scan, browser uses $rd
if (UTIL::is_cli()) {
    foreach (($GLOBALS['argv'] ?? []) as $arg) {
        $lower = strtolower(ltrim($arg, '-'));
        if ($lower === 'debug' || strpos($lower, 'debug=') === 0) $debug = true;
    }
} else {
    if (isset($rd['debug'])) $debug = true;
}

Output Helpers

Define simple wrapper functions around UTIL::print() to give your output consistent structure:

function out_info(string $msg): void  { UTIL::print("[INFO]  " . $msg); }
function out_warn(string $msg): void  { UTIL::print("[WARN]  " . $msg); }
function out_divider(): void          { UTIL::print(str_repeat('=', 72)); }
function out_preformatted(string $text): void { UTIL::print($text); }

UTIL::print() handles both CLI (stdout) and browser (HTML) rendering automatically. Use it for all output - normal results, warnings, and errors alike. Do not use echo directly.

Layout - Optional Page Rendering

\CodePunch\UI\Layout->show() is an optional alternative that renders a complete WMD page with application chrome - navigation, header, and footer. Use it when you want your tool's output to appear as a proper WMD UI page rather than raw printed output.

To use it, collect everything you want to display into a heading string and a body string, then pass them together:

$layout = new \CodePunch\UI\Layout();
$layout->show([
    'heading' => '<h3>My Tool</h3>',
    'body'    => $collectedOutput,
]);

Important: because Layout->show() renders a full page, you must not call UTIL::print() before it in the same request. If you use Layout, collect all output into a string first, then pass it to show() in one call.

For most simple tools, UTIL::print() throughout is perfectly sufficient - including for error output. Layout is only worth using when you specifically want the WMD page chrome around your output. For browser-facing tools that need full HTML control, output a self-contained HTML page directly using header('Content-Type: text/html; charset=utf-8') and echo or inline PHP - this avoids any dependency on Layout entirely.


Error Handling

Wrap all tool logic in a try/catch block. Log the error via CPLogger and print it with UTIL::print() - this works correctly in both CLI and browser with no mode branching needed:

try {
    // Use READONLY_TABLES or, in newer versions, the alias STANDARD_INIT
    $auth = new \CodePunch\Config\Auth(\CodePunch\DB\Database::READONLY_TABLES);
    if (!$auth) {
        throw new Exception("Failed to initialise Auth.");
    }

    $db = $auth->getDatabase();
    if (!$db) {
        throw new Exception("Failed to obtain database handle.");
    }

    // ... tool logic ...
}
catch (Exception $e) {
    $logger = new \CodePunch\Base\CPLogger();
    $logger->error($e->getMessage());
    UTIL::print($e->getMessage() . ' - ' . $e->getFile() . ' - ' . $e->getLine());
    exit;
}

Key points:


Debug Mode

Adding a debug mode to your tool is good practice. A --debug flag in CLI or ?debug=1 in the browser gives you a controlled way to inspect what the tool is doing without modifying the code.

Useful things to expose in debug mode include the SQL being executed, the bound parameter values, and a sample of the raw data returned from the database. This makes it significantly faster to diagnose problems with query logic, column name mismatches, or unexpected data.

When printing debug param arrays that may contain PARAM_INT_ARRAY pairs, map them before passing to implode() - raw arrays will cause an "Array to string conversion" warning:

out_debug("Params: " . implode(', ', array_map(
    fn($p) => is_array($p) ? '[array(' . count($p[0]) . '), PARAM_INT_ARRAY]' : (string)$p,
    array_slice($params, 0, 10)
)), $debug);

Debug output should always be clearly labelled and easy to distinguish from normal tool output.


Skeletal Example

The following is a complete annotated skeleton showing all the patterns above working together.

File Header and Configuration

<?php
function findLoader(): string {
    $dir = __DIR__;
    for ($i = 0; $i < 6; $i++) {
        $candidate = $dir . '/lib/php/loader.php';
        if (file_exists($candidate)) return $candidate;
        $dir = dirname($dir);
    }
    die("Could not locate loader.php  -  ensure this tool is installed within the WMD SED directory tree.
");
}
require findLoader();

const DEFAULT_DAYS        = 30;
const CLIENT_EMAIL_COLUMN = 'client_email';
const UNASSIGNED_KEY      = '__unassigned__';

use CodePunch\Base\Util as UTIL;
use CodePunch\Base\Text as TEXT;
use CodePunch\DB\Audit  as AUDIT;

Bootstrap

if (UTIL::is_cli()) {
    UTIL::parse_request_data_in_cli();
}
$rd = UTIL::get_unsafe_request_data_array();

Flag Resolution

$days = DEFAULT_DAYS;
$debug = $dryRun = false;

if (UTIL::is_cli()) {
    foreach (($GLOBALS['argv'] ?? []) as $arg) {
        $a = strtolower(ltrim($arg, '-'));
        if ($a === 'debug'   || strpos($a, 'debug=')   === 0) $debug  = true;
        if ($a === 'dry-run' || strpos($a, 'dry-run=') === 0) $dryRun = true;
    }
} else {
    if (isset($rd['debug']))   $debug  = true;
    if (isset($rd['dry-run'])) $dryRun = true;
}

if (isset($rd['days']) && (int)$rd['days'] > 0) {
    $days = (int)$rd['days'];
}

Main Try/Catch Block

try {
    $auth = new \CodePunch\Config\Auth(\CodePunch\DB\Database::READONLY_TABLES);
    if (!$auth) throw new Exception("Auth init failed.");

    if (!UTIL::is_cli()) {
        // RECOMMENDED: disable browser access entirely instead:
        // UTIL::print("This tool can only be run from the command line.");
        // exit;

        $status = $auth->validateSession(false, false);
        if ($status !== \CodePunch\Config\Auth::VALID) {
            header("Location: ../login.php");
            exit;
        }

        if (!\CodePunch\Config\Auth::isAdmin()) {
            UTIL::print("Access denied  -  this tool requires administrator privileges.");
            exit;
        }
    }

    $db = $auth->getDatabase();
    if (!$db) throw new Exception("Database handle failed.");

    $table = $db->getDomainTableName();
    $today = date('Y-m-d');

    $rows = $db->getFromTable($columns, $table, $where, $params, 'domain', 'asc', 10000);

    if ($rows === false) throw new Exception("Query failed.");

    foreach ($rows as $row) {
        // process rows...
    }
}
catch (Exception $e) {
    $logger = new \CodePunch\Base\CPLogger();
    $logger->error($e->getMessage());
    UTIL::print($e->getMessage() . ' - ' . $e->getFile() . ' - ' . $e->getLine());
    exit;
}

Checklist


This guide covers direct database access tools only. For the REST API approach see the WMD SED Custom Tools Developer Guide. This guide is published at learn.domainpunch.com/wmdsed/api/native-development/.

Close