Skip to content

Latest commit

 

History

History
2277 lines (1817 loc) · 65.6 KB

File metadata and controls

2277 lines (1817 loc) · 65.6 KB

DVC Framework - Module Development Guide

Project Overview

This project uses the BraveDave DVC Framework, a lightweight PHP MVC framework with modular architecture. All modules follow a consistent pattern for controllers, data access objects (DAO), data transfer objects (DTO), handlers, and views.

AI Navigation

Use these files as the top-level map for AI-enabled workflows in this repository:

  • .github/AI-CODING-INDEX.md - canonical index of prompts, skills, agents, and standards
  • .github/AI-README.md - practical workflow guide and governance conventions for AI workflows
  • .github/prompts/api-contract-maintenance.standard.md - required API parity rules when postHandler() actions change

Contextual governance convention:

  • README.md in any folder is authoritative for that folder and should always be read for context before making changes there.
  • Exception: in .github/, AI-README.md is authoritative to avoid default GitHub README rendering.

Key Principles:

  • Modular Architecture: Each feature is a self-contained module with its own namespace
  • DAO/DTO Pattern: All data handling must go through Data Access Objects and Data Transfer Objects
  • POST Handler Routing: Updates are posted via dedicated handler classes with static methods
  • Auto-Migration: Database schemas are declarative and auto-migrate
  • Type Safety: DTOs provide typed data containers with IDE autocomplete support

Module Structure Template

Every module follows this standardized structure:

src/app/{module-name}/
├── config.php                    # Module configuration & DB version
├── controller.php                # Routes GET requests, defines POST routing
├── handler.php                   # Processes POST requests (business logic)
├── dao/
│   ├── dbinfo.php               # Database version maintenance
│   ├── {entity}.php             # Data Access Object (one per entity)
│   ├── dto/
│   │   └── {entity}.php         # Data Transfer Object (matches DAO)
│   └── db/
│       └── {entity}.php         # Database schema definition
└── views/
    ├── index.php                # Sidebar/navigation view
    ├── matrix.php               # Main data grid/list view
    └── edit.php                 # Modal form for create/edit

Reference Implementation Examples

Complete working examples are available in .github/examples/:

  • Simple CRUD Pattern: .github/examples/todo/ - Data-centric maintenance interface
  • Rich CRUD Workbench: .github/examples/contacts/ - Workflow-oriented engagement interface

These examples demonstrate:

  • Complete file structure and organization
  • Controller patterns and routing
  • Handler business logic and POST processing
  • DAO/DTO implementation with database schema
  • View templates and JavaScript patterns
  • Best practices and naming conventions

When creating new modules, refer to these examples as authoritative references. See .github/examples/README.md for detailed comparison.

Route Files (src/controller/{module}.php)

Purpose: Maps URL segments to module controllers. The framework looks for a class matching the URL segment name.

Route File Pattern

<?php
// file: src/controller/{module}.php
class {module} extends {namespace}\controller {}

Examples:

// file: src/controller/todo.php
class todo extends todo\controller {}

// file: src/controller/users.php
class users extends cms\users\controller {}

// file: src/controller/home.php
class home extends home\controller {}

Key Points

  1. Class name must match URL segment - For /users URL, the class must be named users
  2. Extend the module controller - The class extends the controller from the module namespace
  3. Empty class body - No methods needed; all logic lives in the module controller
  4. One file per route - Each URL segment gets its own file in src/controller/

How Routing Works

URL: /users/edit/5
     ↓
Framework looks for: src/controller/users.php
     ↓
Finds class: users extends cms\users\controller
     ↓
Instantiates controller, calls: edit(5)

Common Mistakes

// ❌ WRONG - Do not instantiate the controller
$controller = new cms\users\controller(__DIR__);

// ❌ WRONG - Class name doesn't match URL segment
class user extends cms\users\controller {}  // Should be 'users' for /users URL

// ✅ CORRECT - Class extends module controller
class users extends cms\users\controller {}

Nested Module Namespaces

For modules in nested namespaces like cms\users:

// file: src/controller/users.php
class users extends cms\users\controller {}

The route file class name (users) maps to the URL, while the extended class (cms\users\controller) can be in any namespace structure.

1. Module Configuration (config.php)

Purpose: Extends root config, defines version constant, ensures database is current.

<?php
namespace {module};

use config as rootConfig;

class config extends rootConfig {
  const {module}_db_version = 1;
  const label = '{Module Display Name}';

  static function {module}_checkdatabase() {
    $dao = new dao\dbinfo;
    $dao->checkVersion('{module}', self::{module}_db_version);
  }
}

Key Points:

  • Always extend the application's root config class
  • Version constant format: {module}_db_version
  • Database check method called before each request via before() hook
  • Increment version to trigger schema migrations

Example from src/app/todo/config.php:

namespace todo;

use config as rootConfig;

class config extends rootConfig {
  const todo_db_version = 1;
  const label = 'Todo';

  static function todo_checkdatabase() {
    $dao = new dao\dbinfo;
    $dao->checkVersion('todo', self::todo_db_version);
  }
}

2. Controller (controller.php)

Purpose: Routes GET requests to views, POST requests to handlers via postHandler().

Controller Structure

<?php
namespace {module};

use bravedave\dvc\{ controller as dvcController, ServerRequest};

class controller extends dvcController {

  // Default index page - protected method handles GET /module
  protected function _index() {
    $this->data = (object)[
      'title' => $this->title = config::label,
    ];

    $this->renderBS5([
      'aside' => fn() => $this->load('index'),
      'main' => fn() => $this->load('matrix')
    ]);
  }

  // Lifecycle hook - runs before every request
  protected function before() {
    config::{module}_checkdatabase();    // Ensure DB is current
    parent::before();
    $this->viewPath[] = __DIR__ . '/views/';  // Register view directory
  }

  // Routes all POST requests based on 'action' parameter
  protected function postHandler() {
    $request = new ServerRequest;
    $action = $request('action');

    return match ($action) {
      '{entity}-save' => handler::{entity}Save($request),
      '{entity}-delete' => handler::{entity}Delete($request),
      'get-by-id' => handler::{entity}GetByID($request),
      'get-matrix' => handler::{entity}GetMatrix($request),
      default => parent::postHandler()
    };
  }

  // Public method for GET /module/edit/{id}
  public function edit($id = 0) {
    $this->data = (object)[
      'title' => $this->title = config::label,
      'dto' => new dao\dto\{entity}  // Empty template for new records
    ];

    if ($id = (int)$id) {
      $dao = new dao\{entity};
      $this->data->dto = $dao->getByID($id);  // Load existing record
      $this->data->title .= ' edit';
    }

    $this->load('edit');
  }
}

Key Controller Patterns

1. Protected _index() Method

  • Handles GET requests to base route (e.g., /todo)
  • Prepares $this->data object with data for views
  • Uses renderBS5() for Bootstrap 5 layout with aside/main sections

2. before() Lifecycle Hook

  • Runs before every request (GET and POST)
  • Check database version/schema
  • Register module's view directory
  • Always call parent::before() to maintain framework lifecycle

3. postHandler() Routing

  • Routes ALL POST requests using PHP 8+ match expression
  • Reads action parameter from POST data via ServerRequest
  • Delegates to static handler methods
  • Falls back to parent handler for framework actions

4. Public Methods for GET Routes

  • Example: public function edit($id = 0) handles /module/edit/5
  • Prepare data, load views
  • Type cast parameters: $id = (int)$id

5. View Rendering

$this->load('view-name');              // Loads from module's views/
$this->renderBS5([...]);               // Full page render with layout

6. Search Focus Pattern

  • Set 'searchFocus' => false when a view has its own search input with autofocus.
  • This prevents the global navbar search from stealing focus on page load.
  • Use 'searchFocus' => true only when the view does not provide a local search field.

Example from src/app/todo/controller.php:

namespace todo;

use bravedave\dvc\{ controller as dvcController, ServerRequest};

class controller extends dvcController {

  protected function _index() {
    $this->data = (object)[
      'title' => $this->title = config::label,
    ];

    $this->renderBS5([
      'aside' => fn() => $this->load('index'),
      'main' => fn() => $this->load('matrix')
    ]);
  }

  protected function before() {
    config::todo_checkdatabase();
    parent::before();
    $this->viewPath[] = __DIR__ . '/views/';
  }

  protected function postHandler() {
    $request = new ServerRequest;
    $action = $request('action');

    return match ($action) {
      'todo-delete' => handler::todoDelete($request),
      'get-by-id' => handler::todoGetByID($request),
      'get-matrix' => handler::todoGetMatrix($request),
      'todo-save' => handler::todoSave($request),
      default => parent::postHandler()
    };
  }

  public function edit($id = 0) {
    $this->data = (object)[
      'title' => $this->title = config::label,
      'dto' => new dao\dto\todo
    ];

    if ($id = (int)$id) {
      $dao = new dao\todo;
      $this->data->dto = $dao->getByID($id);
      $this->data->title .= ' edit';
    }

    $this->load('edit');
  }
}

3. Handler (handler.php)

Purpose: Processes POST requests, contains business logic, returns JSON responses. All data operations must go through DAOs.

Handler Structure

<?php
namespace {module};

use bravedave\dvc\{ServerRequest, json};

final class handler {

  public static function {entity}Save(ServerRequest $request): json {
    $action = $request('action');

    // Extract and validate data
    $a = [
      'field1' => $request('field1'),
      'field2' => $request('field2'),
    ];

    $dao = new dao\{entity};

    if ($id = (int)$request('id')) {
      // Update existing record
      $dao->UpdateByID($a, $id);
    } else {
      // Insert new record
      $dao->Insert($a);
    }

    return json::ack($action);
  }

  public static function {entity}Delete(ServerRequest $request): json {
    $action = $request('action');

    if ($id = (int)$request('id')) {
      (new dao\{entity})->delete($id);
      return json::ack($action);
    }

    return json::nak($action);
  }

  public static function {entity}GetByID(ServerRequest $request): json {
    $action = $request('action');

    if ($id = (int)$request('id')) {
      if ($dto = (new dao\{entity})->getByID($id)) {
        return json::ack($action, $dto);
      }
    }

    return json::nak($action);
  }

  public static function {entity}GetMatrix(ServerRequest $request): json {
    $action = $request('action');
    return json::ack($action, (new dao\{entity})->getMatrix());
  }
}

Key Handler Patterns

1. Final Class

  • final class handler - Cannot be extended (design decision)
  • All handler classes should be final

2. Static Methods

  • Handler methods are stateless utilities
  • Type hint: public static function methodName(ServerRequest $request): json
  • Each action gets its own method

3. ServerRequest Access

$request = new ServerRequest;
$value = $request('field_name');              // Get POST data
$value = $request->getQueryParam('field');    // Get GET parameter

4. Type Casting for Security

$id = (int)$request('id');           // Always cast IDs to int
$name = trim($request('name'));      // Sanitize strings

5. JSON Response Pattern

return json::ack($action);              // Success without data
return json::ack($action, $data);       // Success with data payload
return json::nak($action);              // Failure/error
return json::nak($action, $message);    // Failure with custom message

// Returning a DTO - use second parameter so payload is in d.data
return json::ack($action, ['dto' => $dto]);

// Top-level keys via ->add() are intentional when callers expect d.id, d.name, etc.
return json::ack($action)->add('id', $id);

JSON Response Format:

{
  "response": "ack",           // or "nak" for errors
  "description": "action-name", // the action parameter
  "data": {}                     // optional payload (only with ack)
}

Both ack and nak place the second parameter into data.

JavaScript Handling:

// _.growl() automatically handles the response object
_.fetch.post(_.url('route'), { action: 'save' })
  .then(_.growl)        // Shows success or error notification
  .catch(_.growl);      // Shows error notification

// Only check response when you need conditional logic
_.fetch.post(_.url('route'), { action: 'save' })
  .then(d => {
    if ('ack' == d.response) {
      // Perform additional action on success
      refresh();
    }
    _.growl(d);         // Always show notification
  });

Growl-facing Error Text Rule

  • If the client path calls _.growl(response), put user-facing error text in the first json::nak() argument.
// Preferred when growl is used directly
return json::nak('A human readable error message');

// Often less useful for growl-only paths because it surfaces the action text
return json::nak($action, 'A human readable error message');

Client-side vs Server-side Validation Feedback

  • Use Bootstrap validation UI for client-side required-field guards before posting.
  • Use _.growl() for server responses (json::ack / json::nak).
if (!field.val()) {
  field.closest('.js-field-wrap').addClass('was-validated');
  field.trigger('focus');
  return; // Do not post
}

field.closest('.js-field-wrap').removeClass('was-validated');

6. DAO Instantiation

$dao = new dao\{entity};                // Create new instance
$dto = $dao->getByID($id);              // Returns DTO or null
$dao->Insert($array);                   // Returns new ID
$dao->UpdateByID($array, $id);          // Returns rows affected
$dao->delete($id);                      // Deletes record

Example from src/app/todo/handler.php:

namespace todo;

use bravedave\dvc\{ServerRequest, json};

final class handler {

  public static function todoSave(ServerRequest $request): json {
    $action = $request('action');
    $a = [
      'name' => $request('name'),
      'description' => $request('description'),
    ];

    $dao = new dao\todo;
    if ($id = (int)$request('id')) {
      $dao->UpdateByID($a, $id);
    } else {
      $dao->Insert($a);
    }

    return json::ack($action);
  }

  public static function todoDelete(ServerRequest $request): json {
    $action = $request('action');
    if ($id = (int)$request('id')) {
      (new dao\todo)->delete($id);
      return json::ack($action);
    }
    return json::ack($action);
  }

  public static function todoGetByID(ServerRequest $request): json {
    $action = $request('action');
    if ($id = (int)$request('id')) {
      if ($dto = (new dao\todo)->getByID($id)) {
        return json::ack($action, $dto);
      }
    }
    return json::nak($action);
  }

  public static function todoGetMatrix(ServerRequest $request): json {
    $action = $request('action');
    return json::ack($action, (new dao\todo)->getMatrix());
  }
}

4. Data Access Object (dao/{entity}.php)

Purpose: Abstracts all database operations. ALL data handling must go through DAOs - never write raw SQL in controllers or handlers.

DAO Structure

<?php
namespace {module}\dao;

use bravedave\dvc\{dao, dtoSet};

class {entity} extends dao {
  protected $_db_name = '{table_name}';      // Database table name
  protected $template = dto\{entity}::class; // DTO class for results

  // Custom query returning multiple records
  public function getMatrix() : array {
    return (new dtoSet)('SELECT * FROM `{table_name}`');
  }

  // Override Insert to add timestamps
  public function Insert($a) {
    $a['created'] = $a['updated'] = self::dbTimeStamp();
    return parent::Insert($a);
  }

  // Override UpdateByID to update timestamp
  public function UpdateByID($a, $id) {
    $a['updated'] = self::dbTimeStamp();
    return parent::UpdateByID($a, $id);
  }

  // Custom business logic methods
  public function getActiveItems() : array {
    $sql = 'SELECT * FROM `{table_name}` WHERE `active` = 1 ORDER BY `name`';
    return (new dtoSet)($sql);
  }
}

Inherited DAO Base Methods

The framework's base dao class provides these methods automatically:

// Retrieve single record
$dto = $dao->getByID($id);                    // Returns DTO or null

// Retrieve multiple records
$dtos = $dao->getAll($fields = '*', $order = '');  // Returns array of DTOs

// Insert new record
$newId = $dao->Insert($array);                // Returns new ID

// Update existing record
$rows = $dao->UpdateByID($array, $id);        // Returns rows affected

// Delete record
$dao->delete($id);                            // Returns boolean

// Count records
$count = $dao->count();                       // Returns int

Active Record Cache Invalidation

The DVC DAO base class implements active record caching. getByID() checks the cache before querying the database and caches the retrieved DTO. UpdateByID() and delete() automatically invalidate the cache entries for each affected record ID, including cached individual fields.

Prefer DAO mutation methods over direct UPDATE or DELETE queries. A direct mutation bypasses the DAO's record-level invalidation and can leave stale DTOs in the cache. If a direct mutation is unavoidable, flush the cache immediately after the query:

use bravedave\dvc\cache;

$this->db->Q($sql);
cache::instance()->flush();

A full cache flush clears unrelated entries and has significant overhead, so it is a fallback rather than the normal update strategy. For bulk changes, select the affected IDs and update each record through its DAO:

$sql = 'SELECT `id` FROM `table` WHERE `date` < "2025-12-31"';
$dao = new tableDAO;

(new dtoSet)($sql, function ($dto) use ($dao) {
  $dao->UpdateByID([
    'last_year' => 1,
  ], (int)$dto->id);
});

Key DAO Patterns

1. Required Properties

protected $_db_name = 'table_name';           // Maps to database table
protected $template = dto\{entity}::class;    // Links to DTO for typed results

2. dtoSet for Multiple Records

dtoSet is invoked as a callable object that returns an array of DTOs:

// Basic usage - query must be properly escaped
return (new dtoSet)('SELECT * FROM `table`');

// With sprintf for integers (safe)
return (new dtoSet)(sprintf('SELECT * FROM `table` WHERE `id` = %d', $id));

// With quote() for strings (use in DAOs)
$sql = sprintf('SELECT * FROM `table` WHERE `name` = %s', $this->quote($string));
return (new dtoSet)($sql);

// With filter function (second parameter)
return (new dtoSet)($sql, function($dto) {
  // Return $dto to include, null to exclude
  return $dto->active ? $dto : null;
});

// With custom DTO template (third parameter)
return (new dtoSet)($sql, null, dto\custom::class);

dtoSet Parameters:

  1. $sql (string): SQL query - must be properly escaped
  2. $filter (callable|null): Optional function to filter/transform each DTO
  3. $template (string|null): Optional DTO class, overrides DAO's $template property

String Quoting in DAOs:

// Use $this->quote() for string values in SQL
$name = $this->quote($userInput);
$sql = sprintf('SELECT * FROM `table` WHERE `name` = %s', $name);

// Integers can use %d directly
$sql = sprintf('SELECT * FROM `table` WHERE `id` = %d', $id);

3. Timestamp Management

$a['created'] = self::dbTimeStamp();          // MySQL-compatible timestamp
$a['updated'] = self::dbTimeStamp();

4. Clearing Field Values

// Use empty string to clear fields, not null
$a['completed_at'] = '';                      // Clear datetime field
$a['notes'] = '';                             // Clear varchar/text field
$a['count'] = 0;                              // Clear integer field

// BAD - Don't use null
// $a['completed_at'] = null;                 // AVOID THIS

5. Parameterized Queries (when using base dao methods)

  • Framework automatically prepares statements
  • Use ? placeholders for parameters
  • Pass parameters as array to methods like db()->q() or db()->fetch()

6. DAO Invocation and getRichData()

DAOs can be invoked as functions to retrieve and optionally enrich a single record:

// Using DAO as callable - automatically calls getRichData if present
$dao = new dao\{entity};
$dto = $dao($id);  // Same as $dao->getByID($id) but with enrichment

// Manual call to getRichData
$dto = $dao->getByID($id);
if (method_exists($dao, 'getRichData')) {
  $dto = $dao->getRichData($dto);
}

getRichData() Method:

Optional method for DTO enrichment with additional lookups or calculated fields:

public function getRichData(dto $dto): ?dto {
  // Perform additional lookups
  $userDao = new \user\dao\user;
  $dto->user_name = $userDao->getFieldByID($dto->user_id, 'name');

  // Add calculated fields
  $dto->days_old = (time() - strtotime($dto->created)) / 86400;

  // Add related data
  $dto->comments_count = (new \comment\dao\comment)->countByPost($dto->id);

  return $dto;
}

When to use getRichData():

  • ✅ Single record views where additional context is needed
  • ✅ API endpoints returning detailed single records
  • ✅ Edit forms needing related data for dropdowns
  • ❌ List/matrix views (performance impact on multiple records)
  • ❌ High-frequency API calls (additional query overhead)
  • ❌ When base DTO data is sufficient

Performance Note: getRichData adds database queries and processing time. Use judiciously and only when enrichment justifies the performance cost.

Example from src/app/todo/dao/todo.php:

namespace todo\dao;

use bravedave\dvc\{dao, dtoSet};

class todo extends dao {
  protected $_db_name = 'todo';
  protected $template = dto\todo::class;

  public function getMatrix() : array {
    return (new dtoSet)('SELECT * FROM `todo`');
  }

  public function Insert($a) {
    $a['created'] = $a['updated'] = self::dbTimeStamp();
    return parent::Insert($a);
  }

  public function UpdateByID($a, $id) {
    $a['updated'] = self::dbTimeStamp();
    return parent::UpdateByID($a, $id);
  }
}

5. Data Transfer Object (dao/dto/{entity}.php)

Purpose: Typed container for database records. Provides type safety, IDE autocomplete, and consistent data structure.

DTO Structure

<?php
namespace {module}\dao\dto;

use bravedave\dvc\dto;

class {entity} extends dto {
  public $id = 0;
  public $created = '';
  public $updated = '';

  // Entity-specific fields with default values
  public $name = '';
  public $description = '';
  public $status = '';
  public $active = 0;
}

Key DTO Patterns

1. Plain Data Container

  • No business logic in DTOs
  • Only public properties with default values
  • Default values define type expectations

2. Required Standard Fields

public $id = 0;           // Primary key (always added by framework)
public $created = '';     // Timestamp when record created
public $updated = '';     // Timestamp when record updated

3. Type Hints via Defaults

public $count = 0;        // Integer expected
public $name = '';        // String expected
public $price = 0.0;      // Float expected
public $items = [];       // Array expected

4. Extends Framework DTO

  • Inherits JsonSerializable interface
  • Auto-converts to JSON in responses
  • Provides utility methods for data manipulation

5. Usage Patterns

// Empty template for new records
$dto = new dao\dto\{entity};

// From database via DAO
$dto = $dao->getByID($id);

// Manual creation
$dto = new dto\{entity};
$dto->name = 'Example';
$dto->status = 'active';

// Array to DTO
$dto = dao\dto\{entity}::from($array);

Example from src/app/todo/dao/dto/todo.php:

namespace todo\dao\dto;

use bravedave\dvc\dto;

class todo extends dto {
  public $id = 0;
  public $created = '';
  public $updated = '';
  public $name = '';
  public $description = '';
}

6. Database Schema (dao/db/{entity}.php)

Purpose: Declarative schema definition for auto-migration. Schema changes are applied automatically when version increments.

Schema Structure

<?php
/**
 * Database Schema for {entity}
 *
 * Notes:
 * - Primary key 'id' (autoincrement) is added automatically - DO NOT define it
 * - Field types are MySQL format, converted to SQLite equivalents as needed
 * - Schema is checked/updated automatically via dbinfo::checkDIR()
 */

$dbc = \sys::dbCheck('{table_name}');

// Standard timestamp fields (always include)
$dbc->defineField('created', 'datetime');
$dbc->defineField('updated', 'datetime');

// Entity-specific fields
$dbc->defineField('name', 'varchar');
$dbc->defineField('description', 'text');
$dbc->defineField('status', 'varchar', null, null, 'pending');
$dbc->defineField('active', 'tinyint', null, null, 1);
$dbc->defineField('sort_order', 'int', null, null, 0);

// Indexes for performance
$dbc->defineIndex('idx_name', 'name');
$dbc->defineIndex('idx_status_active', 'status, active');

// Execute schema check/migration
$dbc->check();

### Schema Field Types

**String Types:**
```php
$dbc->defineField('name', 'varchar');              // VARCHAR(255)
$dbc->defineField('email', 'varchar', ['length' => 100]);
$dbc->defineField('description', 'text');          // TEXT
$dbc->defineField('content', 'longtext');          // LONGTEXT

Numeric Types:

$dbc->defineField('count', 'int');                 // INT
$dbc->defineField('price', 'decimal', ['length' => '10,2']);
$dbc->defineField('active', 'tinyint');            // TINYINT
$dbc->defineField('bignum', 'bigint');             // BIGINT

Date/Time Types:

$dbc->defineField('created', 'datetime');          // DATETIME
$dbc->defineField('event_date', 'date');           // DATE
$dbc->defineField('event_time', 'time');           // TIME

Field Parameters:

$dbc->defineField(string 'field', string 'type', int 'length', int 'decimals', string 'default value');

Indexes

// Single column index
$dbc->defineIndex('idx_name', 'column_name');

// Multi-column index
$dbc->defineIndex('idx_user_date', 'user_id, created');

// Unique index
$dbc->defineIndex('idx_email', 'email unique');

Key Schema Patterns

1. Auto-Generated ID

  • Primary key id (INT AUTO_INCREMENT) added automatically
  • NEVER define id field manually

2. Always Include Timestamps

$dbc->defineField('created', 'datetime');
$dbc->defineField('updated', 'datetime');

3. Cross-Database Compatibility

  • MySQL and SQLite supported
  • Use MySQL types, framework converts to SQLite as needed

4. Schema Migration

  • Schema file runs when module version increments
  • Only missing fields/indexes are added
  • Existing data is preserved

Example from src/app/todo/dao/db/todo.php:

/**
 * note:
 *  id, autoincrement primary key is added to all tables - no need to specify
 *  field types are MySQL and are converted to SQLite equivalents as required
 */

$dbc = \sys::dbCheck('todo');

$dbc->defineField('created', 'datetime');
$dbc->defineField('updated', 'datetime');
$dbc->defineField('name', 'varchar');
$dbc->defineField('description', 'varchar');

$dbc->check();

7. Database Info (dao/dbinfo.php)

Purpose: Manages database version tracking and executes schema files.

dbinfo Structure

<?php
namespace {module}\dao;

use bravedave\dvc\dbinfo as dvcDbInfo;

class dbinfo extends dvcDbInfo {
  protected function check() {
    parent::check();
    parent::checkDIR(__DIR__);  // Scans dao/db/ directory for schema files
  }
}

Key Points:

  • Always extend framework's dbinfo class
  • checkDIR(__DIR__) scans dao/db/ directory
  • Includes all PHP files found in dao/db/
  • Version tracked in src/data/db_version.json
  • Only runs when config::{module}_db_version increments

8. Views

View Conventions

1. Namespace and Use Statements

<?php
namespace {module};

use bravedave\dvc\strings;  // Required when using strings:: methods
?>
<!-- HTML content -->

Important: Views must include use statements for any framework classes used in the view. Common imports:

  • use bravedave\dvc\strings; - For strings::rand(), strings::url(), etc.
  • use bravedave\dvc\currentUser; - For currentUser:: methods (if not using \currentUser::)
  • use bravedave\dvc\theme; - For theme::modalHeader() and other theme utilities

2. Namespace Views

<?php
namespace {module}; ?>
<!-- HTML content -->

3. Access Controller Data

$this->title           // Page title
$this->data->dto       // DTO object
$this->route           // Controller route

4. Generate Unique IDs

<?php $_uid = strings::rand(); ?>
<div id="<?= $_uid ?>"></div>

Index View (views/index.php)

Simple sidebar/navigation content:

<?php
namespace {module};

use bravedave\dvc\strings; ?>

<h1><?= config::label ?></h1>

<div class="list-group">
  <a href="<?= strings::url('{module}') ?>" class="list-group-item list-group-item-action">View All</a>
  <a href="<?= strings::url('{module}/reports') ?>" class="list-group-item list-group-item-action">Reports</a>
</div>

Matrix View (views/matrix.php)

Main data grid with search, actions, and JavaScript:

<?php
namespace {module};

use bravedave\dvc\strings; ?>

<div class="container-fluid">
  <!-- Search -->
  <div class="row mb-2">
    <div class="col">
      <input type="search" class="form-control" placeholder="search..."
             id="<?= $_search = strings::rand() ?>">
    </div>
    <div class="col-auto">
      <button class="btn btn-outline-primary" id="<?= $_uidAdd = strings::rand() ?>">
        <i class="bi bi-plus-circle"></i> new
      </button>
    </div>
  </div>

  <!-- Data Table -->
  <table class="table table-sm table-hover" id="<?= $_table = strings::rand() ?>">
    <thead>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Description</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody></tbody>
  </table>
</div>

<script>
(_ => {
  const table = $('#<?= $_table ?>');
  const search = $('#<?= $_search ?>');
  const btnAdd = $('#<?= $_uidAdd ?>');

  // Fetch data from server
  const getMatrix = () => new Promise((resolve, reject) => {
    _.fetch.post(_.url('<?= $this->route ?>'), {
      action: 'get-matrix'
    }).then(d => {
      if ('ack' == d.response) {
        resolve(d.data);
      } else {
        _.growl(d);
        reject(d);
      }
    });
  });

  // Render table rows
  const matrix = data => {
    const tbody = table.find('> tbody').empty();

    $.each(data, (i, dto) => {
      $(`<tr data-id="${dto.id}">
        <td>${dto.id}</td>
        <td>${dto.name}</td>
        <td>${dto.description}</td>
        <td>${dto.status || ''}</td>
      </tr>`)
        .on('click', function(e) {
          _.hideContexts(e);  // hides any open contexts and stops propagation
          $(this).trigger('edit');
        })
        .on('contextmenu', contextmenu)
        .on('delete', rowDelete)
        .on('edit', edit)
        .appendTo(tbody);
    });
  };

  // Context menu handler
  const contextmenu = function(e) {

    if (e.shiftKey) return;
    const _ctx = _.context(e); // hides any open contexts and stops bubbling

    _ctx.append.a({
      html: '<i class="bi bi-pencil"></i>edit',
      click: e => $(this).trigger('edit')
    });

    _ctx.open(e);
  };

  // Delete row handler
  const rowDelete = function(e) {
    const tr = $(this);
    const id = tr.data('id');

    _.ask.alert.confirm({
      title: 'Confirm Delete',
      text: 'Are you sure?'
    }).then(() => {
      _.fetch.post(_.url('<?= $this->route ?>'), {
        action: '{entity}-delete',
        id: id
      }).then(d => {
        if ('ack' == d.response) {
          refresh();
        } else {
          _.growl(d);
        }
      });
    });
  };

  // Edit handler - load modal
  const edit = function(e) {
    const tr = $(this);
    const id = tr.data('id');

    _.get.modal(_.url('<?= $this->route ?>/edit/' + id))
      .then(m => {
        m.on('success', () => refresh());
      });
  };

  // Search filter
  let searchTimeout;
  search.on('keyup', function(e) {
    clearTimeout(searchTimeout);
    const term = $(this).val().toLowerCase();

    searchTimeout = setTimeout(() => {
      table.find('> tbody > tr').each(function() {
        const text = $(this).text().toLowerCase();
        $(this).toggle(text.includes(term));
      });
    }, 300);
  });

  // Add button
  btnAdd.on('click', e => {
    _.hideContexts(e);  // hides any open contexts and stops propagation
    _.get.modal(_.url('<?= $this->route ?>/edit'))
      .then(m => m.on('success', () => refresh()));
  });

  // Refresh data
  const refresh = () => {
    getMatrix().then(matrix);
  };

  // Initialize on page ready
  _.ready(() => refresh());

})(_brayworth_);
</script>

Edit View (views/edit.php)

Modal form for create/update:

Note: When a view is loaded via $this->load(), the controller's protectedLoad() method automatically extracts all properties from $this->data into the view's local scope. This means $this->data->dto becomes available as $dto directly in the view without explicit assignment.

CRITICAL — Single Root Element Rule: The edit view MUST have exactly ONE root HTML element (the <form>). The <script> tag must be placed inside the <form> as its last child — never as a sibling after </form>. This same rule applies to any HTML fragment returned to _.get.modal(...), not only views/edit.php.

Why this matters: _.get.modal(url) (in _brayworth_.get.js) fetches the HTML, wraps it in a <div>, appends the wrapper to <body> (which executes inline scripts), then calls _modal.modal('show'). The script must execute before modal('show') is called so that the shown.bs.modal listener is registered in time. If the <script> is a sibling of <form>, jQuery wraps them together and the timing still works, but the <form> is no longer the single root element which causes the framework to search for .modal inside a wrapper div — this is fragile and non-canonical.

Do NOT call modal.modal('show') in the script. The framework calls it at line 51 of _brayworth_.get.js after appending the HTML to the DOM. Calling it again would show the modal twice.

<?php
namespace {module};

use bravedave\dvc\{strings, theme};

// Note: $dto is automatically available from $this->data->dto via protectedLoad() ?>

<form id="<?= $_form = strings::rand() ?>" autocomplete="off">
  <input type="hidden" name="action" value="{entity}-save">
  <input type="hidden" name="id" value="<?= $dto->id ?>">

  <div class="modal fade" id="<?= $_modal = strings::rand() ?>" tabindex="-1">
    <div class="modal-dialog">
      <div class="modal-content">

        <div class="modal-header <?= theme::modalHeader() ?>">
          <h5 class="modal-title"><?= $this->title ?></h5>
          <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
        </div>

        <div class="modal-body">
          <div class="mb-3">
            <label class="form-label">Name</label>
            <input type="text" name="name" class="form-control"
                   value="<?= $dto->name ?>" required autofocus>
          </div>

          <div class="mb-3">
            <label class="form-label">Description</label>
            <textarea name="description" class="form-control"
                      rows="3"><?= $dto->description ?></textarea>
          </div>

          <div class="mb-3">
            <label class="form-label">Status</label>
            <select name="status" class="form-select">
              <option value="pending" <?= $dto->status == 'pending' ? 'selected' : '' ?>>Pending</option>
              <option value="active" <?= $dto->status == 'active' ? 'selected' : '' ?>>Active</option>
              <option value="completed" <?= $dto->status == 'completed' ? 'selected' : '' ?>>Completed</option>
            </select>
          </div>
        </div>

        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
          <button type="submit" class="btn btn-primary">Save</button>
        </div>

      </div>
    </div>
  </div>

  <script>
  (_ => {
    const form = $('#<?= $_form ?>');
    const modal = $('#<?= $_modal ?>');

    modal.on('shown.bs.modal', () => {
      // Focus first input when modal shown
      form.find('[autofocus]').trigger('focus');

      // Form submit handler
      form.on('submit', function(e) {
        e.preventDefault();

        const btn = form.find('[type="submit"]');
        btn.prop('disabled', true);

        _.fetch.post.form(_.url('<?= $this->route ?>'), this)
          .then(d => {
            if ('ack' == d.response) {
              modal.trigger('success');  // Notify parent
              modal.modal('hide');
            } else {
              _.growl(d);
              btn.prop('disabled', false);
            }
          })
          .catch(e => {
            _.growl(e);
            btn.prop('disabled', false);
          });

        return false;
      });
    });

  })(_brayworth_);
  </script>
</form>

Key View Patterns

1. Unique ID Generation

<?= $_uid = strings::rand() ?>    // Generate and output in same line
<div id="<?= $_uid ?>"></div>     // Use the variable

2. JavaScript IIFE Pattern

(_ => {
  // Module code here
  // _ is the _brayworth_ framework object
})(_brayworth_);

3. Framework JavaScript Methods

_.url('path')                           // Generate URL
_.fetch.post(url, data)                 // POST JSON data
_.fetch.post.form(url, formElement)     // POST form data (x-www-form-urlencoded)
_.fetch.post.form(url, formElement, 'multipart/form-data') // POST multipart data
_.get.modal(url)                        // Load modal via GET (framework auto-calls modal('show'))
_.growl(d)                              // Show notification
_.ask.alert.confirm({title, text})      // Confirmation dialog
_.ask.success(message)                  // Success dialog
_.ask.success.confirm(message)          // Success confirmation dialog
_.ready(callback)                       // Document ready
_.hideContexts(e)                       // Hide context menus
_.context(e)                            // Create context menu
_.sanitize(str)                         // Escape HTML for XSS prevention
_.esc(str)                              // Escape HTML for XSS prevention
_.table.search(search, table)           // Framework table search helper
_.asLocaleDate(value)                   // Locale-aware date formatter
_.randomString()                        // Client-side unique ID generator
_.csv.call(tableElement, 'export.csv')  // Export a table to CSV; pass the HTMLTableElement itself

When using _.get.modal(url), follow this contract in every module and view:

  • Returned HTML should have a single root element (normally <form>).
  • Inline <script> must be inside that root element so event handlers bind before the framework shows the modal.
  • Do not call modal.modal('show') inside fetched HTML. _brayworth_.get.js does this after append.
  • Do not use the native HTML <dialog> element for edit forms; fetched edit views must use the Bootstrap 5 .modal structure shown above.
  • Matrix add/edit actions must open the edit view with _.get.modal() and refresh the owning list after the modal emits success.

_.rand() does not exist. Use _.randomString() for client-side unique IDs.

For CSV exports, call the helper on a real table element, for example _.csv.call(table[0], 'export.csv') when table is a jQuery object.

4. Bootstrap 5 Modals

modal.modal('show');                    // Show modal
modal.modal('hide');                    // Hide modal
modal.on('shown.bs.modal', fn);         // Event when shown
modal.trigger('success');               // Custom event

For HTML loaded by _.get.modal(url), prefer modal.modal('hide'), events, and custom triggers only. Let the framework perform modal.modal('show').

5. Form Data Binding

<input name="field" value="<?= $dto->field ?>">

Data Flow Architecture

GET Request Flow (View a Page)

1. Browser: GET /module
   ↓
2. DVC Framework: Route to module\controller
   ↓
3. controller::__construct()
   ↓
4. controller::before()
   - config::module_checkdatabase()
   - Register view paths
   - parent::before()
   ↓
5. controller::_index()
   - Prepare $this->data object
   - Call renderBS5()
   ↓
6. Framework renders Bootstrap 5 layout:
   - aside → views/index.php
   - main → views/matrix.php
   ↓
7. Browser receives HTML with JavaScript
   ↓
8. JavaScript executes on _.ready()
   - Fetches data via POST (action: 'get-matrix')
   - Renders table rows

POST Request Flow (AJAX Data Operation)

1. Browser: POST /module
   Body: {action: 'entity-save', id: 5, name: 'Example', ...}
   ↓
2. DVC Framework: Route to module\controller
   ↓
3. controller::before()
   - Check database
   ↓
4. controller::postHandler()
   - new ServerRequest
   - Read 'action' parameter
   - Match 'entity-save'
   ↓
5. handler::entitySave($request)
   - Extract fields from request
   - Validate/sanitize data
   - new dao\entity
   - Check if update (id exists) or insert
   ↓
6. dao\entity::UpdateByID($array, $id)
   OR
   dao\entity::Insert($array)
   - Add/update timestamps
   - Execute SQL via framework
   - Return ID or rows affected
   ↓
7. handler returns json::ack('entity-save', $data)
   ↓
8. Framework destructs json object:
   - Sets Content-Type: application/json
   - Outputs JSON: {"response":"ack","description":"entity-save","data":{...}}
   ↓
9. Browser receives JSON
   - _.growl(d) automatically displays success or error notification
   - For conditional logic: check d.response === 'ack'
   - Update UI (refresh table, close modal, etc.)
   - Pattern: .then(d => { if ('ack' == d.response) doAction(); _.growl(d); })

Edit Modal Flow (GET + POST Combined)

1. User clicks row or "Add" button
   ↓
2. JavaScript: _.get.modal(_.url('module/edit/5'))
   ↓
3. GET /module/edit/5
   ↓
4. controller::edit(5)
   - new dao\entity
   - $dto = $dao->getByID(5)
   - Prepare $this->data with DTO
   - load('edit')
   ↓
5. views/edit.php renders:
   - Form with hidden inputs (action, id)
   - Input fields bound to DTO values
  - JavaScript that binds modal handlers (submit, focus, success events)
   ↓
6. Browser receives modal HTML
   - Framework injects into page
   - modal.modal('show') executes
   - Returns promise that resolves to modal element
   ↓
7. User edits fields and clicks "Save"
   ↓
8. Form submit event:
   - e.preventDefault()
   - _.fetch.post.form(url, formElement)
   ↓
9. POST /module (action: 'entity-save', id: 5, ...)
   [Follows POST flow above]
   ↓
10. On success response:
    - modal.trigger('success')  // Custom event
    - modal.modal('hide')
    ↓
11. Parent view catches 'success' event:
    - Refreshes table data
    - Shows success notification

Controller Patterns: Simple CRUD vs Rich CRUD Workbench

The DVC framework supports two distinct patterns for module controllers, each suited to different use cases and levels of entity complexity.

Simple CRUD Controller (Data-Centric Pattern)

Purpose: Maintenance-focused interface for straightforward Create, Read, Update, Delete operations on a single entity type.

Use When:

  • Entity has few fields (3-10)
  • No complex relationships or sub-domains
  • Operations are simple field updates
  • Users perform quick maintenance tasks
  • No need for contextual workflows

Architecture:

  • Matrix View: Table listing all records with search
  • Modal Edit: Inline form overlay for create/update
  • Direct Actions: Click row to edit, context menu for delete
  • Stateless: Each operation is independent

Example: Todo module (src/app/todo/)

Key Files:

src/app/todo/
├── views/
│   ├── matrix.php    # Table with row click → edit modal
│   └── edit.php      # Modal form for create/update

Interaction Flow:

1. User views table of records (matrix)
2. Clicks row → edit modal opens
3. Updates fields → saves → modal closes
4. Table refreshes to show changes

View Pattern (matrix.php):

// Row click triggers edit modal
.on('click', function(e) {
  e.stopPropagation();
  $(this).trigger('edit');
})

// Edit handler opens modal
const edit = function() {
  _.get.modal(_.url(`${route}/edit/${this.dataset.id}`))
    .then(m => m.on('success', e => $(this).trigger('refresh')));
};

Characteristics:

  • Single view context (list)
  • Modal-based editing
  • Field-centric operations
  • Fast data entry
  • Minimal navigation

Rich CRUD Workbench (Domain-Centric Pattern)

Purpose: Workflow-oriented interface where users engage deeply with a single record and its related sub-domains, artifacts, and contextual actions.

Use When:

  • Entity has complex relationships (invoices, notes, history, attachments)
  • Business processes require multiple related views/actions
  • Users spend extended time working on a single record
  • Context switching between related data is common
  • Task-based workflows are needed

Architecture:

  • Matrix View: Discovery interface for finding records (shallow context)
  • Workbench View: Deep engagement interface for working on a single record
  • Tab System: Related sub-domains as tabs (invoices, notes, history, etc.)
  • Contextual Actions: Task-based operations specific to the current record
  • Accordion Toggle: Switch between discovery (matrix) and engagement (workbench)

Example: Contacts module (src/app/contacts/)

Key Files:

src/app/contacts/
├── controller.php
│   └── view($id)     # Dedicated controller method for workbench view
├── views/
│   ├── matrix.php    # Accordion with feed + workbench areas
│   ├── view.php      # Read-only detail view template (loaded in workbench tab)
│   └── edit.php      # Modal form (triggered from workbench actions)

Interaction Flow:

1. User views matrix (feed) to discover records
2. Clicks row → workbench opens, matrix collapses
3. Workbench loads initial tab with record details (view)
4. User can:
   - Add tabs for related data (invoices, notes)
   - Trigger actions (edit, email, generate report)
   - Navigate between tabs without leaving record context
5. Close button returns to matrix (discovery mode)

View Pattern (matrix.php with accordion):

<div class="accordion" id="<?= $_uidAccordion ?>">
  <!-- Feed (Matrix) Section -->
  <div class="accordion-item">
    <div id="accordion-feed" class="accordion-collapse collapse show">
      <!-- Table with search and records -->
    </div>
  </div>

  <!-- Workbench Section -->
  <div class="accordion-item">
    <div id="accordion-workbench" class="accordion-collapse collapse">
      <nav class="navbar">
        <div class="navbar-brand">Workbench</div>
        <button class="btn-close" data-bs-toggle="collapse"
                data-bs-target="#accordion-feed"></button>
      </nav>
      <!-- Dynamic tabs loaded here via _.tabs() -->
    </div>
  </div>
</div>

JavaScript Viewer Pattern:

// Row click triggers workbench with tabs
.on('click', function(e) {
  e.stopPropagation();
  $(this).trigger('view');
})

// Viewer creates tab system
const viewer = function(e) {
  const tabs = _.tabs(workbench);  // Initialize tab system
  const view = tabs.newTab('view'); // Create initial tab

  // Load view template via AJAX
  view.pane.on('refresh', e => {
    _.fetch.get(_.url(`${route}/view/${this.dataset.id}`))
      .then(html => view.pane.html(html));
  });

  // Add contextual action buttons
  const btnEdit = $('<button>edit</button>').appendTo(tabs.nav);
  btnEdit.on('click', e => {
    _.get.modal(_.url(`${route}/edit/${this.dataset.id}`))
      .then(m => m.on('success', e => {
        view.tab.trigger('show.bs.tab');  // Refresh current tab
        rowRefresh.call(this, e);         // Update matrix row
      }));
  });

  workbench.collapse('show');  // Open workbench, collapse matrix
  view.tab.tab('show');        // Activate initial tab
};

Controller Addition (view method):

public function view($id = 0) {
  if ($id = (int)$id) {
    $dao = new dao\contacts;
    if ($dto = $dao->getByID($id)) {
      $this->data = (object)[
        'title' => $this->title = config::label_view,
        'dto' => $dto
      ];
      $this->load('view');  // Load view.php template
    }
  }
}

Characteristics:

  • Dual view contexts: discovery (matrix) + engagement (workbench)
  • Tab-based navigation within record context
  • Task-based actions (not just field updates)
  • Related sub-domain views (invoices, notes, history)
  • Dedicated read-only view template
  • Persistent context while working on a record

Pattern Comparison

Aspect Simple CRUD Rich CRUD Workbench
Focus Data maintenance Business workflows
View Depth Shallow (list only) Deep (record context + related)
Edit Mode Modal overlay Modal + workbench tabs
Navigation Linear (list → edit → list) Contextual (list → workbench → tabs)
Actions Field-based (update, delete) Task-based (edit, email, report)
Use Case Simple entities Complex entities with relationships
View Files matrix.php, edit.php matrix.php, view.php, edit.php
Controller _index(), edit() _index(), view(), edit()
Row Click Opens edit modal Opens workbench with view tab
UI Pattern Table + modal Accordion (feed ↔ workbench)

When to Use Each Pattern

Choose Simple CRUD when:

  • Entity is self-contained (no complex relationships)
  • Users perform quick data entry/updates
  • No workflow or process context needed
  • Examples: todos, tags, categories, simple configuration

Choose Rich CRUD Workbench when:

  • Entity has related sub-domains (customer → invoices, notes)
  • Users need to work on one record for extended periods
  • Context switching between related data is common
  • Business tasks require multiple views/actions
  • Examples: contacts, projects, orders, cases

Implementation Notes

Simple CRUD:

  • Row click: $(this).trigger('edit') → opens modal
  • No view() controller method needed
  • No view.php template needed
  • Matrix is always visible

Rich CRUD Workbench:

  • Row click: $(this).trigger('view') → opens workbench
  • Requires view($id) controller method
  • Requires view.php template for tab content
  • Matrix collapses when workbench opens
  • Use _.tabs(element) to create dynamic tab system
  • Accordion structure with data-bs-parent for mutual exclusivity

Tab System (_.tabs API):

const tabs = _.tabs(containerElement);  // Initialize tab system in element
const tab = tabs.newTab('tabId');       // Create new tab
tab.pane                                 // Access tab content pane
tab.tab                                  // Access tab navigation element
tabs.nav                                 // Access navigation bar for buttons

Module Creation Checklist

Use this checklist when creating a new module:

1. Module Structure

  • Create directory: src/app/{module}/
  • Create subdirectories: dao/, dao/dto/, dao/db/, views/

2. Configuration

  • Create config.php extending root config
  • Define {module}_db_version constant
  • Define label constant
  • Create {module}_checkdatabase() static method

3. Database

  • Create dao/dbinfo.php extending framework dbinfo
  • Create dao/db/{entity}.php with schema definition
  • Define standard fields: created, updated
  • Define entity-specific fields
  • Add indexes for performance

4. Data Layer

  • Create dao/dto/{entity}.php extending framework dto
  • Define public properties with default values
  • Include standard fields: id, created, updated
  • Create dao/{entity}.php extending framework dao
  • Set $_db_name and $template properties
  • Override Insert() to add timestamps
  • Override UpdateByID() to update timestamp
  • Add custom query methods (e.g., getMatrix())

5. Controller

  • Create controller.php extending dvc controller
  • Implement before() hook:
    • Call config::{module}_checkdatabase()
    • Call parent::before()
    • Register view path
  • Implement _index() for default view
  • Implement postHandler() with match expression
  • Add public methods for other GET routes (e.g., edit())

6. Handler

  • Create handler.php as final class
  • Implement static methods for each action:
    • {entity}Save(ServerRequest $request): json
    • {entity}Delete(ServerRequest $request): json
    • {entity}GetByID(ServerRequest $request): json
    • {entity}GetMatrix(ServerRequest $request): json
  • Type cast all user input
  • Use DAO for all data operations
  • Return json::ack() or json::nak()

7. Views

  • Create views/index.php (sidebar/navigation)
  • Create views/matrix.php (main data grid):
    • Search input with unique ID
    • Action buttons (Add, etc.)
    • Data table with unique ID
    • JavaScript IIFE with brayworth
    • getMatrix() function
    • matrix() render function
    • Event handlers (click, contextmenu, delete, edit)
    • refresh() and _.ready() initialization
  • Create views/edit.php (modal form):
    • Form with unique ID
    • Hidden inputs for action and id
    • Input fields bound to DTO
    • Modal with unique ID
    • JavaScript for form submit
    • modal.trigger('success') on save

8. Route File

  • Create src/controller/{module}.php
  • Define class extending module controller: class {module} extends {namespace}\controller {}
  • Class name must match URL segment

9. Integration

  • Test GET routes (index, edit)
  • Test POST actions (save, delete, get data)
  • Test modal create/edit flow
  • Test search/filter functionality
  • Test context menu and row actions
  • Verify database migrations

10. Optional Enhancements

  • Add sorting to matrix view
  • Add pagination for large datasets
  • Add export functionality (CSV, PDF)
  • Add bulk actions (bulk delete, bulk update)
  • Add advanced filtering
  • Add validation to handler methods
  • Add error logging
  • Add unit tests

CLI Module Generator

Use the DVC CLI to quickly scaffold a new module:

# Generate module structure automatically
vendor/bin/dvc make::module {module-name}

This creates the basic file structure. You still need to:

  1. Define database schema in dao/db/
  2. Add DTO properties
  3. Implement handler methods
  4. Create view templates
  5. Add custom DAO methods

Naming Conventions

Files and Directories

  • Module names: Lowercase, singular (e.g., todo, user, product)
  • Controller file: controller.php (always)
  • Handler file: handler.php (always)
  • DAO files: Singular entity name (e.g., todo.php, user.php)
  • DTO files: Match DAO name (e.g., dto/todo.php)
  • Schema files: Match table name (e.g., db/todo.php)

Classes and Namespaces

  • Namespace: namespace {module}; (e.g., namespace todo;)
  • Controller class: class controller extends dvcController
  • Handler class: final class handler
  • DAO class: class {entity} extends dao
  • DTO class: class {entity} extends dto

Methods and Actions

  • Action names: Kebab-case with module prefix (e.g., todo-save, todo-delete)
  • Handler methods: camelCase with entity prefix (e.g., todoSave, todoDelete)
  • DAO methods: camelCase (e.g., getMatrix, getActiveItems)
  • Controller methods: camelCase (e.g., _index, edit)

Database

  • Table names: Lowercase, singular (e.g., todo, user, product)
  • Field names: Lowercase with underscores (e.g., created_at, user_id)
  • Standard fields: id, created, updated (always include)

Framework Utilities

Response Class (bravedave\dvc\Response)

The Response class provides static methods for HTTP responses and redirects.

Redirect Methods:

use bravedave\dvc\Response;

// Redirect to home page
Response::redirect();                           // Redirects to /
Response::redirect(null);                       // Same as above
Response::redirect('/');                        // Explicit home

// Redirect with flash message (shown after redirect)
Response::redirect(null, 'user logged out');    // Redirects to / with message
Response::redirect('/', 'user logged out');     // Same as above
Response::redirect('/dashboard', 'Welcome!');   // Redirect to specific path with message

// Redirect to specific URL
Response::redirect('/users');                   // Redirects to /users
Response::redirect('/module/action/123');       // Redirects to specific route

When to use Response::redirect():

  • ✅ Logout actions - redirect user after clearing session
  • ✅ After successful form submission (POST-Redirect-GET pattern)
  • ✅ Access denied - redirect to login or home
  • ✅ After completing a workflow step
  • ❌ AJAX/API endpoints - use json::ack() or json::nak() instead

Note: Response::redirect() terminates script execution. Code after the call will not run.

Security Best Practices

Input Validation

// Always type cast IDs
$id = (int)$request('id');

// Sanitize strings
$name = trim($request('name'));

// Validate required fields
if (empty($name)) {
  return json::nak($action, 'Name is required');
}

// Validate email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
  return json::nak($action, 'Invalid email');
}

SQL Injection Prevention

// GOOD - Use DAO methods (prepared statements)
$dao->getByID($id);
$dao->UpdateByID($array, $id);

// GOOD - Use sprintf for integers, quote() for strings in DAOs
$sql = sprintf('SELECT * FROM table WHERE id = %d', $id);
new dtoSet($sql);

// Or with quote() method for strings
$sql = sprintf('SELECT * FROM table WHERE name = %s', $this->quote($name));
new dtoSet($sql);

// BAD - Never concatenate user input into SQL
// new dtoSet("SELECT * FROM table WHERE id = $id");  // NEVER DO THIS

XSS Prevention

// PREFERRED: Use bravedave\dvc\esc() function for output escaping
use function bravedave\dvc\esc;

echo esc($userInput);                  // In PHP code
esc($dto->name)                        // In mixed PHP/HTML

// Or use short form (framework provides)
<?= $dto->name ?>  // Framework auto-escapes in most contexts

esc() Function Benefits:

  • Centralized escaping preferences and configuration
  • Consistent behavior across the entire application
  • Shorter, more readable syntax than htmlspecialchars
  • Framework-managed encoding and flag preferences
  • Prefer bravedave\dvc\esc() over htmlspecialchars() and htmlentities() for normal HTML text and attribute escaping
  • Use htmlentities() only when you explicitly need broad character-to-entity conversion rather than standard HTML escaping, which should be rare
  • Always use bravedave\dvc\esc() for new code and when updating existing code unless one of the above exceptions applies

Authentication/Authorization

// Check user permissions in controller before()
protected function before() {
  if (!currentUser::valid()) {
    Response::redirect('/login');
  }
  parent::before();
}

// Check permissions in handler
public static function entityDelete(ServerRequest $request): json {
  if (!currentUser::isadmin()) {
    return json::nak($request('action'), 'Permission denied');
  }
  // ... proceed with delete
}

Performance Optimization

Database Queries

// GOOD - Single query with JOIN
$sql = 'SELECT u.*, p.name as product_name
        FROM users u
        LEFT JOIN products p ON p.user_id = u.id';
$users = new dtoSet($sql);

// BAD - N+1 query problem
// $users = $dao->getAll();
// foreach ($users as $user) {
//   $user->product = (new productDao)->getByUserId($user->id); // Multiple queries!
// }

Caching

// Cache expensive queries
public function getMatrix() : array {
  $cache = \cache::get('entity-matrix');
  if ($cache) return $cache;

  $data = (new dtoSet)('SELECT * FROM entity');
  \cache::set('entity-matrix', $data, 300); // 5 minutes
  return $data;
}

Pagination

// Add pagination to large datasets
public function getMatrix($page = 1, $perPage = 50) : array {
  $offset = ($page - 1) * $perPage;
  $sql = sprintf('SELECT * FROM entity LIMIT %d OFFSET %d', $perPage, $offset);
  return (new dtoSet)($sql);
}

Testing Patterns

Handler Unit Test Example

use PHPUnit\Framework\TestCase;

class HandlerTest extends TestCase {
  public function testTodoSave() {
    $request = new ServerRequest([
      'action' => 'todo-save',
      'name' => 'Test Task',
      'description' => 'Test Description'
    ]);

    $response = handler::todoSave($request);

    $this->assertEquals('ack', $response->response);
  }
}

DAO Unit Test Example

class TodoDaoTest extends TestCase {
  public function testInsertAndRetrieve() {
    $dao = new dao\todo;

    $id = $dao->Insert([
      'name' => 'Test',
      'description' => 'Test Description'
    ]);

    $this->assertGreaterThan(0, $id);

    $dto = $dao->getByID($id);
    $this->assertEquals('Test', $dto->name);
  }
}

Common Patterns and Examples

Master-Detail Relationship

// In parent DAO
public function getWithChildren($id) {
  $dto = $this->getByID($id);
  if ($dto) {
    $dto->children = (new childDao)->getByParentId($id);
  }
  return $dto;
}

// In child DAO
public function getByParentId($parentId) : array {
  $sql = sprintf('SELECT * FROM child WHERE parent_id = %d', (int)$parentId);
  return (new dtoSet)($sql);
}

Soft Delete

// Add 'deleted' field to schema
$dbc->defineField('deleted', 'tinyint');  // default = 0

// Override delete in DAO
public function delete($id) {
  return $this->UpdateByID(['deleted' => 1], $id);
}

// Filter deleted in queries
public function getMatrix() : array {
  return (new dtoSet)('SELECT * FROM entity WHERE deleted = 0');
}

File Upload Handler

public static function entityUpload(ServerRequest $request): json {
  $action = $request('action');

  if ($file = $request->file('upload')) {
    $target = sprintf('%s/uploads/%s',
      \sys::config()->paths->upload,
      $file->getClientFilename()
    );

    $file->moveTo($target);

    return json::ack($action, ['filename' => $file->getClientFilename()]);
  }

  return json::nak($action, 'No file uploaded');
}

Export to CSV

public function exportCsv() {
  $dao = new dao\todo;
  $data = $dao->getMatrix();

  header('Content-Type: text/csv');
  header('Content-Disposition: attachment; filename="export.csv"');

  $fp = fopen('php://output', 'w');
  fputcsv($fp, ['ID', 'Name', 'Description', 'Created']);

  foreach ($data as $dto) {
    fputcsv($fp, [$dto->id, $dto->name, $dto->description, $dto->created]);
  }

  fclose($fp);
  exit;
}

Troubleshooting

Database Not Updating

  • Check config::{module}_db_version is incremented
  • Verify dao/dbinfo.php calls checkDIR(__DIR__)
  • Check src/data/db_version.json current version
  • Verify schema file has $dbc->check() at end

POST Handler Not Found

  • Verify action name matches in JavaScript and postHandler()
  • Check handler method is static and public
  • Ensure handler method returns json type
  • Verify ServerRequest spelling and usage

DTO Not Populating

  • Check DAO $template property points to correct DTO class
  • Verify DTO properties match database field names
  • Ensure DTO extends framework dto class

Views Not Loading

  • Verify view path registered in controller::before()
  • Check view file has namespace declaration
  • Ensure view file exists in views/ directory

JavaScript Errors

  • Check unique IDs generated with strings::rand()
  • Verify _brayworth_ framework loaded
  • Check console for JavaScript errors
  • Ensure IIFE pattern wrapped correctly

Additional Resources

  • DVC Framework Documentation: vendor/bravedave/dvc/README.md
  • Standards Document: vendor/bravedave/dvc/STANDARDS.md
  • Todo Module Example: src/app/todo/
  • CLI Help: vendor/bin/dvc --help

Quick Reference Card

Essential Commands

# Create new module
vendor/bin/dvc make::module {name}

# Start dev server
vendor/bin/dvc serve

# Setup application
vendor/bin/dvc make::application

Common Code Snippets

Create DAO instance and fetch:

$dao = new dao\{entity};
$dto = $dao->getByID($id);
$all = $dao->getMatrix();

Insert/Update:

$data = ['field' => 'value'];
$id = $dao->Insert($data);
$dao->UpdateByID($data, $id);

JSON Response:

return json::ack($action, $data);
return json::nak($action, $message);

AJAX POST:

_.fetch.post(_.url('route'), {
  action: 'action-name',
  data: value
}).then(d => {
  if ('ack' == d.response) {
    // Success
  }
});

Load Modal:

_.get.modal(_.url('route/edit/' + id))
  .then(m => {
    m.on('success', () => refresh());
  });

This guide represents the canonical pattern for creating modules in the DVC framework. Always refer to src/app/todo/ as the reference implementation.