ProcessWire
Recipes_

processwire-advanced-modules

What I Do

I provide guidance for complex ProcessWire module development scenarios:

  • Building rich Admin Interfaces (Process modules)
  • Implementing Secure Configuration patterns
  • Creating advanced Fieldtypes (Multi-value, Virtual Templates)
  • Handling System Operations (remote requests, file ops, preflight checks)
  • Creating Installers and sub-modules
  • Managing Custom Database Tables
  • Implementing AJAX and dynamic assets
  • Multi-step wizards with session state
  • Post-render operations with __destruct()
  • Iterable modules with IteratorAggregate/Countable

When to Use Me

Use this skill when:

  • You need to create a backend tool with forms, tables, or wizards
  • You are handling sensitive API keys or credentials in a module
  • You are developing a complex Fieldtype that needs to store structural data
  • You need to download files or interact with external APIs reliably
  • You need to perform environment checks before installation
  • You need to store high-volume or non-page data in custom tables
  • You need to inject Javascript/CSS into the admin interface

Multi-Step Wizards

Build complex import/export or configuration wizards spanning multiple steps.

Session State Management

Use namespaced session storage to persist data between steps:

class ImportPagesCSV extends Process implements Module {

    protected function sessionGet($key, $fallback = null) {
        $value = $this->session->getFor($this, $key);
        return $value === null ? $fallback : $value;
    }

    protected function sessionSet($key, $value) {
        $this->session->setFor($this, $key, $value);
    }

    public function ___execute() {
        // Step 1
        $form = $this->buildForm1();
        if($this->input->post('submit')) {
            if($this->processForm1($form)) {
                $this->session->redirect('./fields/');
            }
        }
        return $form->render();
    }

    public function ___executeFields() {
        // Step 2 - retrieve session data
        $template = $this->templates->get($this->sessionGet('csvTemplate'));
        $parent = $this->pages->get($this->sessionGet('csvParent'));
        $csvFilename = $this->sessionGet('csvFilename');

        if(!$template || !$parent->id || !$csvFilename) {
            $this->error("Missing required data");
            $this->session->redirect('../');
        }
        // ... process step 2
    }
}

Wizard Navigation

public function ___execute() {
    // Clear previous wizard state on fresh start
    if(!$this->input->post('submit')) {
        $this->sessionSet('wizard_step', 1);
        $this->sessionSet('wizard_data', []);
    }
    
    $step = (int) $this->sessionGet('wizard_step', 1);
    return $this->renderStep($step);
}

Iterable Modules

Make modules foreach-able and countable like collections.

Implementing IteratorAggregate and Countable

class MarkupLoadRSS extends WireData implements Module, \IteratorAggregate, \Countable {

    protected $items = null;

    public function init() {
        $this->items = new WireArray();
    }

    /**
     * Make module foreach-able
     */
    public function getIterator(): \Traversable {
        return $this->items;
    }

    /**
     * Make module countable
     */
    public function count(): int {
        return count($this->items);
    }
}

// Usage
$rss = $modules->get('MarkupLoadRSS');
$rss->load('https://example.com/feed.rss');

echo "Found " . count($rss) . " items\n";
foreach($rss as $item) {
    echo "$item->title\n";
}

Companion Data Classes

Create dedicated classes for items:

class MarkupLoadRSSItem extends WireData {
    public function get($key) {
        // Translate aliases
        if($key == 'url') $key = 'link';
        if($key == 'date') $key = 'pubDate';
        if($key == 'body') $key = 'description';
        return parent::get($key);
    }
}

Post-Render Operations

Execute file operations after the page has rendered using __destruct().

Pattern: File Renames After Render

class ProcessWireUpgrade extends Process {

    protected $renames = [];

    /**
     * Schedule a rename for after render
     */
    protected function renameLater($oldPath, $newPath) {
        $this->renames[$oldPath] = $newPath;
        $this->message("Scheduled rename: " . basename($oldPath) . " => " . basename($newPath));
    }

    /**
     * Execute scheduled renames after page render
     */
    public function __destruct() {
        if(!count($this->renames)) return;
        
        foreach($this->renames as $oldPath => $newPath) {
            if(file_exists($newPath)) {
                // Handle existing destination
                $n = 0;
                do {
                    $newPath2 = $newPath . "-" . (++$n);
                } while(file_exists($newPath2));
                rename($newPath, $newPath2);
            }
            
            if(rename($oldPath, $newPath)) {
                $this->message("Renamed: " . basename($oldPath) . " => " . basename($newPath));
            } else {
                $this->error("Failed: " . basename($oldPath));
            }
        }
        $this->renames = [];
    }
}

Use this pattern when replacing core files - the response sends before the rename happens.


Building Admin Interfaces

Admin interfaces are built using Process modules (Process implements Module). They map URL segments to methods.

Basic Process Module

class ProcessMyTool extends Process implements Module {
    public static function getModuleInfo() {
        return [
            'title' => 'My Tool',
            'page' => [
                'name' => 'my-tool',
                'parent' => 'setup',
                'title' => 'My Tool'
            ]
        ];
    }

    public function execute() {
        return "<h1>Hello Admin</h1>";
    }
}

Building Forms

Use the InputfieldForm API to create forms consistent with the admin theme.

public function execute() {
    /** @var InputfieldForm $form */
    $form = $this->modules->get("InputfieldForm");
    
    // Text Input
    $f = $this->modules->get("InputfieldText");
    $f->name = 'username';
    $f->label = 'Username';
    $f->required = true;
    $form->add($f);

    // Submit Button
    $f = $this->modules->get("InputfieldSubmit");
    $f->name = 'submit';
    $form->add($f);

    // Process Input
    if($this->input->post('submit')) {
        $form->processInput($this->input->post);
        if(!$form->getErrors()) {
            $this->message("Saved!");
            // $this->session->redirect('./');
        }
    }

    return $form->render();
}

Data Tables

Use MarkupAdminDataTable to display listed data.

/** @var MarkupAdminDataTable $table */
$table = $this->modules->get('MarkupAdminDataTable');
$table->setEncodeEntities(false); // If you want HTML in cells
$table->headerRow(['Title', 'Date', 'Status', 'Actions']);

foreach($items as $item) {
    $table->row([
        $item->title,
        date('Y-m-d', $item->created),
        $item->active ? 'Active' : 'Inactive',
        "<a href='./edit/?id=$item->id'>Edit</a>"
    ]);
}

return $table->render();

Multi-Step Wizards

Use session state to manage multi-step processes (like imports).

public function execute() {
    // Step 1
    if($this->input->post('step1_submit')) {
        $this->session->setFor($this, 'import_data', $data);
        $this->session->redirect('./step2/');
    }
    return $this->buildStep1Form()->render();
}

public function executeStep2() {
    // Step 2
    $data = $this->session->getFor($this, 'import_data');
    if(!$data) $this->session->redirect('./'); // Restart if lost
    
    // ... logic ...
}

UI Enhancements & Assets

Injecting Scripts and Styles

You can inject assets conditionally in your module’s init() or ready() methods.

public function ready() {
    // Only load in admin
    if($this->page->template != 'admin') return;

    // Load assets
    $url = $this->config->urls->{$this->className};
    $this->config->scripts->add($url . "my-script.js");
    $this->config->styles->add($url . "my-style.css");
    
    // Pass PHP config to JS
    $this->config->js($this->className, [
        'ajaxUrl' => $this->page->url . 'ajax/',
        'confirmMsg' => $this->_('Are you sure?')
    ]);
}

Hooking Inputfields for UI Injection

To add custom UI elements to specific fields (like autocomplete or buttons), hook Inputfield::render.

public function ready() {
    $this->addHookBefore('InputfieldName::render', function($event) {
        $inputfield = $event->object;
        if($inputfield->name !== 'target_field') return;
        
        $inputfield->appendMarkup = "<script>...</script>";
        // or
        $inputfield->prependMarkup = "<div class='hint'>Hint</div>";
    });
}

Dynamic Asset Generation

For complex configurations, generate a static JS file from PHP config instead of inline JS.

protected function createJsFile($configData) {
    $content = "var myConfig = " . json_encode($configData) . ";";
    $path = $this->config->paths->{$this->className} . "config.js";
    file_put_contents($path, $content);
}

Custom Database Tables

For high-volume data or data that doesn’t fit the Page model, use custom tables.

Creating Tables

Create tables in ___install() and drop them in ___uninstall().

public function ___install() {
    $sql = "
        CREATE TABLE " . self::TABLE_NAME . " (
            id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
            data TEXT,
            created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    ";
    $this->database->query($sql);
}

public function ___uninstall() {
    $this->database->query("DROP TABLE IF EXISTS " . self::TABLE_NAME);
}

Querying Data

Use $this->database (PDO) for queries.

// Select
$stmt = $this->database->prepare("SELECT * FROM " . self::TABLE_NAME . " WHERE id=:id");
$stmt->bindValue(':id', $id, \PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(\PDO::FETCH_OBJ);

// Insert
$stmt = $this->database->prepare("INSERT INTO " . self::TABLE_NAME . " (data) VALUES (:data)");
$stmt->bindValue(':data', $value);
$stmt->execute();
$id = $this->database->lastInsertId();

AJAX Handling

Handle AJAX requests within your module methods.

public function executeAjax() {
    // Check if AJAX
    if(!$this->config->ajax) throw new Wire404Exception();
    
    // Validate CSRF (recommended for POST)
    if($this->input->requestMethod('POST')) {
        $this->session->CSRF->validate();
    }

    // Process
    $data = ['success' => true, 'message' => 'Done'];
    
    // Return JSON
    header('Content-Type: application/json');
    return json_encode($data);
}

Secure Configuration Pattern

Allow sensitive credentials (API keys, passwords) to be overridden via site/config.php so they aren’t stored in the database.

1. In getModuleConfigInputfields

public static function getModuleConfigInputfields(array $data) {
    $inputfields = new InputfieldWrapper();
    $siteConfig = wire('config')->myModuleSettings; // Check site/config.php

    $f = wire('modules')->get('InputfieldText');
    $f->name = 'apiKey';
    $f->label = 'API Key';
    
    // Check if overridden
    if(isset($siteConfig['apiKey'])) {
        $f->value = '********'; // Mask value
        $f->attr('disabled', 'disabled');
        $f->notes = "Controlled by site/config.php";
    } else {
        $f->value = isset($data['apiKey']) ? $data['apiKey'] : '';
    }
    
    $inputfields->add($f);
    return $inputfields;
}

2. In Module Logic

Merge configuration:

public function getSettings() {
    // Default settings
    $settings = ['apiKey' => $this->apiKey];
    
    // Merge overrides
    $siteConfig = $this->wire('config')->myModuleSettings;
    if(is_array($siteConfig)) {
        $settings = array_merge($settings, $siteConfig);
    }
    
    return $settings;
}

Advanced Fieldtypes: FieldtypeMulti

For fields storing multiple related values (like a table or list), extend FieldtypeMulti with custom data classes.

Custom Database Schema

Define multiple columns beyond the default data:

class FieldtypeEvents extends FieldtypeMulti {

    public function getDatabaseSchema(Field $field) {
        $schema = parent::getDatabaseSchema($field);
        
        // 'data' is required - we use it for 'date'
        $schema['data'] = 'DATE NOT NULL';
        $schema['title'] = 'TEXT NOT NULL';
        
        // Add indexes for searchable fields
        $schema['keys']['data'] = 'KEY data(data)';
        $schema['keys']['title'] = 'FULLTEXT title(title)';
        
        return $schema;
    }
}

Custom Data Classes

Create WireData for individual items and WireArray for collections:

// Individual item
class Event extends WireData {
    public function __construct() {
        $this->set('date', '');
        $this->set('title', '');
        $this->set('formatted', false);
        parent::__construct();
    }

    public function set($key, $value) {
        if($key === 'date') {
            $value = $value ? wireDate('Y-m-d', $value) : '';
        } else if($key === 'title') {
            $value = $this->sanitizer->text($value);
        }
        return parent::set($key, $value);
    }

    public function __toString() {
        return "$this->date: $this->title";
    }
}

// Collection
class EventArray extends WireArray {
    public function isValidItem($item) {
        return $item instanceof Event;
    }

    public function __toString() {
        $a = [];
        foreach($this as $item) $a[] = (string) $item;
        return implode("\n", $a);
    }
}

Value Conversion: wakeupValue / sleepValue

Convert between database arrays and PHP objects:

public function getBlankValue(Page $page, Field $field) {
    return new EventArray();
}

public function ___wakeupValue(Page $page, Field $field, $value) {
    $events = $this->getBlankValue($page, $field);
    
    if(empty($value) || !is_array($value)) return $events;
    
    foreach($value as $v) {
        $event = new Event();
        $event->date = $v['data'];  // DB 'data' -> 'date'
        $event->title = $v['title'];
        $event->resetTrackChanges();
        $events->add($event);
    }
    
    $events->resetTrackChanges();
    return $events;
}

public function ___sleepValue(Page $page, Field $field, $value) {
    $sleepValue = [];
    if(!$value instanceof EventArray) return $sleepValue;
    
    $value->sort('date');
    
    foreach($value as $event) {
        if(!$event->date) continue;
        if($event->formatted) {
            throw new WireException('Formatted events cannot be saved');
        }
        $sleepValue[] = [
            'data' => $event->date,  // 'date' -> DB 'data'
            'title' => $event->title
        ];
    }
    
    return $sleepValue;
}

Format Value for Output

public function ___formatValue(Page $page, Field $field, $value) {
    $events = $this->getBlankValue($page, $field);
    
    foreach($value as $event) {
        if(!$event->formatted) {
            $event = clone $event;
            $event->title = $this->sanitizer->entities($event->title);
            $event->formatted = true;
        }
        $events->add($event);
    }
    
    return $events;
}

Custom Selector Queries

Enable finding pages by field subfields:

public function getMatchQuery($query, $table, $subfield, $operator, $value) {
    if($subfield == 'date') {
        $subfield = 'data';  // Map to DB column
    }
    
    if($subfield === 'data') {
        $value = wireDate('Y-m-d', $value);
    } else if($subfield === 'title') {
        $finder = new DatabaseQuerySelectFulltext($query);
        $finder->match($table, $subfield, $operator, $value);
        return $query;
    }
    
    return parent::getMatchQuery($query, $table, $subfield, $operator, $value);
}

Advanced Fieldtypes: Virtual Templates

For complex Fieldtypes (like FieldtypeFieldsetGroup) that need to define a schema of sub-fields, you can create “Virtual Templates”.

Concept

Create a system template that defines the field structure, but is never used for actual pages accessible to users.

Implementation

protected function createVirtualTemplate(Field $field) {
    $name = "fieldset_" . $field->id;
    
    // 1. Create Fieldgroup
    $fieldgroup = new Fieldgroup();
    $fieldgroup->name = $name;
    $fieldgroup->save();
    
    // 2. Create Template
    $template = new Template();
    $template->name = $name;
    $template->fieldgroup = $fieldgroup;
    $template->flags = Template::flagSystem; // Protect it
    $template->noChildren = 1;
    $template->noParents = 1; // Prevent creation
    $template->save();
    
    return $template;
}

System Operations

Robust Remote Requests

Use WireHttp for external API calls:

$http = new WireHttp();
$http->setTimeout(10);
$http->setHeader('User-Agent', 'MyModule/1.0');

$json = $http->get('https://api.example.com/data');
if($json === false) {
    $this->error("HTTP Error: " . $http->getError());
} else {
    $data = json_decode($json, true);
}

HTTP Requests with Caching

Cache remote data to avoid repeated requests:

class MarkupLoadRSS extends WireData implements Module {
    protected $cachePath;

    public function __construct() {
        $this->cachePath = $this->config->paths->cache . $this->className() . '/';
    }

    protected function loadXmlData($url) {
        $cacheFile = $this->cachePath . md5($url) . '.xml.cache';
        $cacheSeconds = 120; // 2 minutes

        if(!is_file($cacheFile) || time() - filemtime($cacheFile) > $cacheSeconds) {
            // Cache expired or missing - fetch fresh
            $http = new WireHttp();
            $this->wire($http);
            $xmlData = $http->get($url);
            
            if(empty($xmlData)) {
                $this->error("Unable to load: $url");
                return false;
            }
            
            // Save to cache
            @file_put_contents($cacheFile, $xmlData, LOCK_EX);
        } else {
            // Load from cache
            $xmlData = file_get_contents($cacheFile);
        }
        
        return $xmlData;
    }

    public function ___install() {
        if(!is_dir($this->cachePath)) {
            wireMkdir($this->cachePath);
        }
    }

    public function ___uninstall() {
        // Clean up cache files
        $dir = new \DirectoryIterator($this->cachePath);
        foreach($dir as $file) {
            if($file->isFile()) unlink($file->getPathname());
        }
        wireRmdir($this->cachePath);
    }
}

File Operations

Use WireFileTools (available as $files API variable).

// Unzip
$files = $this->wire('files')->unzip($zipPath, $destinationDir);

// Remove directory recursively
$this->wire('files')->rmdir($dirPath, true);

// Temp directory
$tempDir = $this->wire('files')->tempDir('my-module');

Preflight Checks

Check environment capability before sensitive operations.

public function checkEnvironment() {
    if(version_compare(PHP_VERSION, '7.4.0', '<')) {
        throw new WireException("PHP 7.4+ required");
    }
    
    if(!class_exists('ZipArchive')) {
        throw new WireException("ZipArchive extension missing");
    }
    
    if(!is_writable($this->wire('config')->paths->assets)) {
        throw new WireException("Assets directory must be writable");
    }
}

Installer Pattern

For complex suites, use a main Process module that installs functionality sub-modules (autoload WireData modules) via the installs property.

// Main Module (Process)
public static function getModuleInfo() {
    return [
        'title' => 'My Suite',
        'installs' => ['MySuiteWorker', 'MySuiteCron'],
    ];
}

// Sub-module (Worker)
public static function getModuleInfo() {
    return [
        'title' => 'My Suite Worker',
        'autoload' => true,
        'requires' => 'MySuite', // Prevents standalone install
    ];
}

Installer Pattern

For complex suites, use a main Process module that installs functionality sub-modules.

Main Module with Sub-Module Installer

// Main Module (Process) - creates admin page and UI
class ProcessWireUpgrade extends Process {
    public static function getModuleInfo() {
        return [
            'title' => 'Upgrades',
            'version' => 11,
            'installs' => 'ProcessWireUpgradeCheck',  // Auto-install helper
            'requires' => 'ProcessWire>=3.0.0',
        ];
    }

    public function init() {
        // Ensure sub-module is available
        $this->checker = $this->modules->getInstall('ProcessWireUpgradeCheck');
        if(!$this->checker) {
            throw new WireException("Please refresh modules");
        }
        parent::init();
    }
}

// Sub-module (Worker) - handles version checking
class ProcessWireUpgradeCheck extends WireData implements Module {
    public static function getModuleInfo() {
        return [
            'title' => 'Upgrade Checker',
            'version' => 1,
            'autoload' => false,
            'requires' => 'ProcessWireUpgrade',  // Prevents standalone install
        ];
    }

    public function getVersions($refresh = false) {
        // Check for updates...
    }
}

Creating Admin Pages on Install

Manual page creation (alternative to auto-creation via page property):

public function ___install() {
    $page = new Page();
    $page->template = 'admin';
    $page->name = 'my-tool';
    $page->parent = $this->pages->get($this->config->adminRootPageID)->child('name=setup');
    $page->process = $this;
    $page->title = 'My Tool';
    $page->save();
    
    $this->message("Created Page: {$page->path}");
}

public function ___uninstall() {
    $moduleID = $this->modules->getModuleID($this);
    $page = $this->pages->get("template=admin, process=$moduleID, name=my-tool");
    
    if($page->id) {
        $this->message("Deleting Page: {$page->path}");
        $page->delete();
    }
}

When to use manual vs auto-creation:

MethodWhen to Use
Auto (page property)Simple admin pages, standard setup
Manual (in ___install())Multiple pages, custom parent, conditional creation

External Info File with Translations

For translatable module info, use an external .info.php file:

// MyModule.info.php
<?php namespace ProcessWire;
$info = [
    'title' => __('My Tool', __FILE__),
    'summary' => __('Description of my tool', __FILE__),
    'version' => 1,
    'permission' => 'my-tool',
    'permissions' => [
        'my-tool' => __('Access My Tool', __FILE__),
    ],
    'page' => [
        'name' => 'my-tool',
        'parent' => 'setup',
        'title' => __('My Tool', __FILE__),
    ],
    'nav' => [
        ['url' => './', 'label' => __('View', __FILE__), 'icon' => 'list'],
        ['url' => 'add/', 'label' => __('Add', __FILE__), 'icon' => 'plus'],
    ],
];
// MyModule.module - load the info file
public function init() {
    parent::init();
    include(__DIR__ . '/MyModule.info.php');
    // $info is now available
    $this->labels = [
        'title' => $info['title'],
        // ... other labels
    ];
}

Strategy/Factory Pattern

For extensible modules that support multiple drivers or engines (like Template Engines), use a Factory + Strategy pattern.

Interface (Strategy)

Define a contract for drivers.

interface EngineInterface {
    public function render($template, $data);
}

Factory

Register and retrieve drivers.

class EngineFactory extends WireData implements Module {
    protected $engines = [];

    public function registerEngine($name, EngineInterface $engine) {
        $this->engines[$name] = $engine;
    }

    public function getEngine($name) {
        return isset($this->engines[$name]) ? $this->engines[$name] : null;
    }
}

Module Config with Testing

Allow users to test configuration settings before saving.

Config with Test Interface

class WireMailRouter extends WireMail implements Module, ConfigurableModule {

    public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {
        $modules = $this->wire('modules');
        
        // ... regular config fields ...
        
        // Add testing textarea
        $f = $modules->get('InputfieldTextarea');
        $f->attr('name', '_test_emails');
        $f->label = 'Test Configuration';
        $f->description = 'Enter email addresses to test which mailer would be used.';
        $f->icon = 'flask';
        $f->collapsed = Inputfield::collapsedYes;
        $inputfields->add($f);
        
        // Process tests on submit
        $tests = $this->wire('input')->post('_test_emails');
        if($tests) {
            $tests = explode("\n", $tests);
            $results = $this->runTests($tests);
            
            $table = $modules->get('MarkupAdminDataTable');
            $table->headerRow(['Email', 'Mailer', 'Matched Rule']);
            foreach($results as $result) {
                $table->row(array_values($result));
            }
            
            $this->message('Test Results:' . $table->render(), Notice::allowMarkup);
        }
        
        return $inputfields;
    }

    public function runTests(array $tests) {
        $results = [];
        foreach($tests as $test) {
            $test = trim($test);
            if(empty($test)) continue;
            
            list($mailer, $rule) = $this->chooseMailer($test);
            $results[] = [
                'email' => $test,
                'mailer' => $mailer,
                'rule' => $rule ?: 'None'
            ];
        }
        return $results;
    }
}