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
- Security Warning
- Overview
- Two Approaches to Extending WMD SED
- Where to Place Your Tool
- Bootstrapping: The Loader
- Dual Mode: CLI and Browser
- Authentication and Database Access
- Querying the Domain Table
- Querying by Category
- Array Parameters in getFromTable()
- Creating Custom Domain Columns
- Handling CLI Flags and Browser Parameters
- Output Helpers
- Error Handling
- Debug Mode
- Skeletal Example
- Checklist
Overview
WMD SED exposes two ways to interact with it programmatically:
- The REST API (
api.php) - session-based, JSON responses, suitable for browser-based tools and external integrations. Documented atlearn.domainpunch.com/wmdsed/api/. - Direct database access - server-side PHP scripts that load WMD's own internal classes and talk to the database directly. Suitable for CLI automation, cron jobs, and tasks that need to run outside a browser session.
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:
- Your tool is browser-based (HTML + JavaScript)
- You need to integrate from an external server or domain
- You want to work within WMD's session and permission model
- You don't have shell access to the server
Direct Access Tools
Use direct database access when:
- Your tool runs as a cron job or scheduled task
- You need to run from the CLI without a browser session
- You want to enrich or update domain data in bulk
- You are calling third-party APIs and writing results back to the domain table
- You need access to internal WMD utility classes for output, logging, or configuration
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:
- The first argument is the page to show after validation. Passing
falsesuppresses any page rendering by Auth. - The second argument controls the swipe-in behaviour. Passing
falsedisables it. - Do not call
validateSession()without arguments. Auth will attempt to render the login page without full UI initialisation, producing a broken page.
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:
| Constant | Value | Meaning |
|---|---|---|
Auth::VALID | 0 | Session is active and authenticated - proceed |
Auth::TIMEDOUT | 1 | Session has expired |
Auth::INVALID | -1 | Session is not authenticated |
Auth::UNKNOWN | -2 | Session state cannot be determined |
Auth::ERROR | -3 | An 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:
| Method | Returns |
|---|---|
$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
| Column | Description |
|---|---|
sid | Internal domain ID (primary key) |
domain | Domain name |
registry_expiry | Expiry date from registry WHOIS |
registrar_expiry | Expiry date from registrar WHOIS |
status | EPP status codes (comma-separated string) |
availability | Domain availability status e.g. Not Available, available, possibly available |
primary_whois_checked_at | Timestamp of last registry WHOIS lookup |
secondary_whois_checked_at | Timestamp 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
| Type | length required | gridview recommendation |
|---|---|---|
string | Yes | 1 - shown in domain grid |
text | No | 0 - too large for grid |
integer | No | 1 |
datetime | No | 1 |
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:
- Always sanitise the column name with
strtolower()+UTIL::strip_punctuation()before use lengthis required forstringtype and must be omitted fortext,integer, anddatetime- Set
gridviewto0fortextcolumns - they are too large to display in the domain grid - Log every custom column addition via
AUDIT::add()so it appears in the WMD admin audit log - If Step 2 fails after Step 1 succeeded, the physical column exists but WMD won't know about it - handle this explicitly
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:
| Mode | Key=value parameter | Bare 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:
- Always throw explicitly when
$author$dbreturn falsy - do not use silentif ($auth) { ... }nesting, which swallows failures. - Always check
getFromTable()for=== falsebefore checkingempty(). CPLoggerwrites to WMD's own log, making errors visible in the WMD admin log viewer.- There is no need to branch on
UTIL::is_cli()in the catch block -UTIL::print()handles both modes correctly on its own.
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
- Unless browser access is explicitly required, exit immediately when not in CLI mode - place
if (!UTIL::is_cli()) { ... exit; }at the top of the tool before any other logic - File is in
wmdsed/user-tools/, notwmdsed/tools/ - The loader is required at the top of the tool - either as
require '../lib/php/loader.php'for fixed-depth tools, or viafindLoader()for portable tools that may be placed at varying depths within the WMD directory tree UTIL::parse_request_data_in_cli()is called beforeget_unsafe_request_data_array()in CLI mode- Bare CLI flags are detected via
$argvscan, not just$rd AuthandgetDatabase()return values are checked and throw on failureDatabase::READONLY_TABLESis used for all Auth instantiation - this does not restrict write operations, it only skips startup diagnostics.ALL_TABLESdoes not exist and will cause a fatal error.- In newer versions,
Database::STANDARD_INITis a clearer alias forREADONLY_TABLESand is preferred for new tools. - In browser mode,
$auth->validateSession(false, false)is called immediately afterAuthinstantiation and before any database access - any status other thanAuth::VALIDredirects to login immediately - In browser mode,
\CodePunch\Config\Auth::isAdmin()is checked after session validation - non-admin users are rejected even with a valid session - Table names are never hardcoded - always use
getDomainTableName()and the other table name methods - Category filtering always uses
findOneOf()+getCategoryConnectionTableName()- never acategorycolumn on the domain table - Array parameters (e.g.
IN (?)) are always passed as[[$array, PARAM_INT_ARRAY]]- never as a plain flat array - All SQL uses
?placeholders with a$paramsarray getFromTable()return value is checked for=== false- When fetching all rows, the WHERE clause is
'1'- not an empty string - All output goes through
UTIL::print()- including errors in the catch block - If using
Layout->show(), all output is collected into a string first and passed as the body - never mixed withUTIL::print()calls in the same request - Exceptions are caught, logged via
CPLogger, and printed viaUTIL::print() - A debug mode is provided to inspect SQL, parameters, and raw data without modifying the code
- A dry-run mode is provided for tools that write or send data, showing what would happen without doing it
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/.