# Pw Module Development

> Use when building, structuring, or refactoring native backend modules for ProcessWire using PHP 8.4 and strict typing.

- Skill: `trk/pw-module-development` (Agent Skill)
- Install (CLI): `npx skillmds@latest add trk/pw-module-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/trk/pw-module-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: trk (https://skillmd.com/u/trk)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/trk/pw-module-development

---


# ProcessWire Core Module Development (wire-module-development)

This skill dictates the principles of building **Modules** (not simple plugins) for the ProcessWire CMS ecosystem. It requires "deep thinking", "zero tolerance for structural flaws", adherence to "strict typing", and total integration with ProcessWire's core philosophy. It is tailored specifically for ProcessWire 3.x architectures and PHP 8+ standards.

## Pre-Computation / Anti-Rationalization Check

Before writing any module code, run through this architectural checklist:

- **Is the Module Type Correct?** Should it be `autoload` (runs on every request), `singular` (single instance), or only initialized in the admin interface? Do not arbitrarily make modules `autoload` if they do not require global event hooks.
- **Hook vs Override:** ProcessWire's strength lies in its Hook system. Never modify core files. Have you precisely identified which method you are hooking into (e.g., `Pages::saveReady`)?
- **Global `wire()` vs Dependency Injection:** Within the module's scope, use `$this->pages` or `$this->wire('pages')`. Avoid utilizing the global `wire()` function inside module classes.
- **PHP 8.4 Syntax:** Have you implemented Constructor Property Promotion, `readonly` classes, and property type declarations? Is `declare(strict_types=1);` at the top of the file?
- **Autonomous Namespacing:** Will the module utilize its own `composer.json`? Has a dedicated `src/` folder been established for PSR-4 autoloading?

## Execution Phases

### Phase 1: Architectural Planning & Scaffolding

1. Create the module's root directory (e.g., `site/modules/MyCustomModule/`) and the primary file `MyCustomModule.module.php`.
2. The module class must implement the `ProcessWire\Module` interface (or extend `ProcessWire\WireData`). Depending on the use case, implement `Module` (standard) or `ConfigurableModule` (for graphical settings).
3. Draft the mandatory `public static function getModuleInfo()` method using modern array notation (including `version => 100`, title, summary, and constraints like `requires => ['ProcessWire>=3.0.210', 'PHP>=8.4.0']`).
4. If utilizing Composer within the module, require the `vendor/autoload.php` inside the module's `init()` method.

### Phase 2: PHP 8.4 & Strict Types Integration

- Files must begin with `declare(strict_types=1);`.
- Enforce comprehensive type hinting for all properties, arguments, and return types.
- To create new hookable methods within the module, prefix the method name with three underscores (`___`): `public function ___myCustomMethod()`.

```php
<?php

declare(strict_types=1);

namespace MyCustomModule;

use ProcessWire\Module;
use ProcessWire\WireData;
use ProcessWire\HookEvent;

class MyCustomModule extends WireData implements Module
{
    // PHP 8.4 Property Promotion & Strict Typing
    public function __construct(
        protected readonly string $logName = 'my-custom-module',
        public int $defaultLimit = 10
    ) {}

    public static function getModuleInfo(): array
    {
        return [
            'title' => 'My Custom Module',
            'version' => '0.1.1',
            'summary' => 'Executes custom business logic effectively.',
            'autoload' => true,
            'singular' => true,
            'requires' => [
                'ProcessWire>=3.0.210',
                'PHP>=8.4.0',
            ],
            'icon' => 'cogs'
        ];
    }

    public function init(): void
    {
        // Module-scoped composer autoloading
        $autoloader = __DIR__ . '/vendor/autoload.php';
        if (file_exists($autoloader)) {
            require_once $autoloader;
        }

        // Attach system Hooks
        $this->addHookAfter('Pages::saveReady', $this, 'hookSaveReady');
    }

    protected function hookSaveReady(HookEvent $event): void
    {
        $page = $event->arguments(0);

        if ($page->template->name !== 'my_target_template') {
            return;
        }

        // Execute business logic...
        $this->wire()->log->save($this->logName, "Page {$page->id} triggered.");
    }
}
```

### Phase 3: Database & Security (PDO & Sanitizer)

- **Never trust user input.** Whether it originates from a URL or a POST payload, sanitize it thoroughly before utilization: `$this->wire()->input->post('email', 'email')` or `$this->wire()->sanitizer->text($string)`.
- When executing raw queries (e.g., custom module tables), utilize the `$this->wire()->database` object. In ProcessWire this is typically a `WireDatabasePDO` wrapper (PDO-like API), not a native `PDO` instance. **Always use prepared statements.** Do not use string interpolation for SQL queries.
- During module uninstallation (`___uninstall()`), ensure complete cleanup of custom database tables, cache files, and residual data.

```php
// Database Best Practice:
$database = $this->wire()->database;
$query = $database->prepare("SELECT id, data FROM custom_table WHERE status = :status");
$query->bindValue(':status', 1, \PDO::PARAM_INT);
$query->execute();
$results = $query->fetchAll(\PDO::FETCH_ASSOC);
```

### Phase 4: Inter-Module Communication & API Variables

- Inside a class extending `WireData` or `Wire`, do not use `wire()` to fetch other objects. Use direct property access like `$this->pages`, `$this->modules`, `$this->sanitizer`, `$this->input`.
- When attempting to access other modules, utilize `$this->modules->get('ModuleName')` and verify the instance against null, ensuring configuration and state correctly boot up prior to interaction.

## Essential Tools & Ecosystem

- `composer` (Only permitted when module-specific, isolated third-party logic is actually necessary).
- `wire tinker` (Command-line REPL for instantly observing ProcessWire code and directly interacting with API objects).

## Copy-Paste Prompts

(Pass these direct prompts to the agent to initiate workflows instantly)

**[Scaffolding - Core Module Skeleton]**

> "Generate a new ProcessWire module named `CustomLogger`. It should be configured as `autoload` and `singular`. Define a `Pages::saveReady` hook inside `init()`. The hook needs to log an entry (under `custom-logger` in ProcessWire system logs) exclusively for pages matching the `article` template. Strictly follow the wire-module-development skill protocols: use PHP 8.4 features, valid strict types, and robust class structures."

**[Database Integration - Automated Installer]**

> "Implement `___install()` and `___uninstall()` methodology for the module. Upon installation, structurally generate an InnoDB database table named `custom_log_table` featuring 3 columns: id, page_id, and message (utilize utf8mb4 charset). During removal, appropriately drop this table. Write highly secure PDO execution code without any interpolation."

**[Security Protocol - Input Sanitization]**

> "Develop a `processInput` method leveraging the ProcessWire Input API. Methodically pull 3 fields (`title`, `email`, `description`) received via POST. Filter and secure them utilizing the designated sanitizer components (`$sanitizer->email()`, `$sanitizer->text()`, etc.) and store the clean results in an output array."

## Core Anti-Patterns to Avoid

- `$pages->find("name={$_GET['name']}")` -> Severe Security Vuln! Selectors must not accept unrestrained inputs without applying Sanitizer.
- `\ProcessWire\wire('pages')` -> Calling the global function from within a module is a poor and computationally unnecessary pattern. Instead: `$this->wire()->pages` or `$this->pages`.
- Empty Try-Catch -> `try { ... } catch(\Exception $e) {}` -> Swallowing exceptions obscures debugging. Continually implement error logging: `$this->wire()->log->error($e->getMessage())`.
- Hard-coded strings in native language -> All user-facing strings must remain localized out of the box using `$this->_('English String')`. Translations are handled via the ProcessWire translation UI.

## Context Awareness (ProcessWire API Docs)

**CRITICAL RULE FOR ALL AI AGENTS:**
When you need to understand, use, or hook into a ProcessWire core class or module, you **MUST NEVER** guess or hallucinate the API methods.

- You **MUST** consult the local AI-optimized Markdown API documentation starting at `.agents/docs/index.md`.
- Navigate through the index, find the relevant class document (e.g. `.agents/docs/core/Page.md`), and use your file reading tools to read its methods, parameters, and hookable (🪝) events before writing any code.

---

## Appendix: PHP 8.4+ Advanced Patterns

> See also: `pw-expert` for detailed API variable access patterns across different contexts.

### Generators for Large Datasets

When processing many pages, use generators to avoid memory exhaustion:

```php
function iteratePages(Pages $pages, string $selector): Generator
{
    $start = 0;
    $limit = 100;

    while (true) {
        $batch = $pages->find("{$selector}, start={$start}, limit={$limit}");
        if ($batch->count() === 0) {
            break;
        }

        foreach ($batch as $page) {
            yield $page;
        }

        $start += $limit;
        $pages->uncacheAll();
    }
}
```

### Enums for Field Values

```php
enum PageStatus: int
{
    case Draft = 0;
    case Published = 1;
    case Hidden = 1024;
    case Unpublished = 2048;
}
```

### Match Expressions

```php
$icon = match ($page->template->name) {
    'article' => 'file-text',
    'gallery' => 'images',
    'contact' => 'envelope',
    default => 'file',
};
```

### Memory Management

- Call `$pages->uncacheAll()` after processing large batches
- Use `$page->of(false)` before modifying output-formatted pages
- Avoid loading all pages into arrays — use iterators
- Prefer `$pages->count($selector)` over `$pages->find($selector)->count()`

### Error Handling

```php
use ProcessWire\WireException;

if (!$field || !$field->id) {
    throw new WireException("Field '{$fieldName}' not found.");
}
```

- Use `WireException` for ProcessWire-specific errors
- Use `\RuntimeException` for general PHP errors
- Never silently swallow exceptions in production code
- Log errors with `$this->wire()->log->error()` in modules

### Code Organization

- **Namespace:** Always `namespace ProcessWire;` for templates, modules, and migrations
- **Classes:** `final class` by default — only remove `final` if inheritance is explicitly needed
- **Methods:** Short, single-responsibility — max 25 lines per method
- **Constants:** Use class constants or enums, not magic strings/numbers
- **Naming:** `$camelCase` for variables, `PascalCase` for classes, `snake_case` for database field names

