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.
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 whenpostHandler()actions change
Contextual governance convention:
README.mdin any folder is authoritative for that folder and should always be read for context before making changes there.- Exception: in
.github/,AI-README.mdis 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
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
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.
Purpose: Maps URL segments to module controllers. The framework looks for a class matching the URL segment name.
<?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 {}- Class name must match URL segment - For
/usersURL, the class must be namedusers - Extend the module controller - The class extends the controller from the module namespace
- Empty class body - No methods needed; all logic lives in the module controller
- One file per route - Each URL segment gets its own file in
src/controller/
URL: /users/edit/5
↓
Framework looks for: src/controller/users.php
↓
Finds class: users extends cms\users\controller
↓
Instantiates controller, calls: edit(5)
// ❌ 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 {}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.
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
configclass - 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);
}
}Purpose: Routes GET requests to views, POST requests to handlers via postHandler().
<?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');
}
}1. Protected _index() Method
- Handles GET requests to base route (e.g.,
/todo) - Prepares
$this->dataobject 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
actionparameter from POST data viaServerRequest - 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 layout6. Search Focus Pattern
- Set
'searchFocus' => falsewhen a view has its own search input with autofocus. - This prevents the global navbar search from stealing focus on page load.
- Use
'searchFocus' => trueonly 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');
}
}Purpose: Processes POST requests, contains business logic, returns JSON responses. All data operations must go through DAOs.
<?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());
}
}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 parameter4. Type Casting for Security
$id = (int)$request('id'); // Always cast IDs to int
$name = trim($request('name')); // Sanitize strings5. 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 firstjson::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 recordExample 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());
}
}Purpose: Abstracts all database operations. ALL data handling must go through DAOs - never write raw SQL in controllers or handlers.
<?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);
}
}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 intThe 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);
});1. Required Properties
protected $_db_name = 'table_name'; // Maps to database table
protected $template = dto\{entity}::class; // Links to DTO for typed results2. 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:
$sql(string): SQL query - must be properly escaped$filter(callable|null): Optional function to filter/transform each DTO$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 THIS5. Parameterized Queries (when using base dao methods)
- Framework automatically prepares statements
- Use
?placeholders for parameters - Pass parameters as array to methods like
db()->q()ordb()->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);
}
}Purpose: Typed container for database records. Provides type safety, IDE autocomplete, and consistent data 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;
}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 updated3. Type Hints via Defaults
public $count = 0; // Integer expected
public $name = ''; // String expected
public $price = 0.0; // Float expected
public $items = []; // Array expected4. Extends Framework DTO
- Inherits
JsonSerializableinterface - 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 = '';
}Purpose: Declarative schema definition for auto-migration. Schema changes are applied automatically when version increments.
<?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'); // LONGTEXTNumeric Types:
$dbc->defineField('count', 'int'); // INT
$dbc->defineField('price', 'decimal', ['length' => '10,2']);
$dbc->defineField('active', 'tinyint'); // TINYINT
$dbc->defineField('bignum', 'bigint'); // BIGINTDate/Time Types:
$dbc->defineField('created', 'datetime'); // DATETIME
$dbc->defineField('event_date', 'date'); // DATE
$dbc->defineField('event_time', 'time'); // TIMEField Parameters:
$dbc->defineField(string 'field', string 'type', int 'length', int 'decimals', string 'default value');// 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');1. Auto-Generated ID
- Primary key
id(INT AUTO_INCREMENT) added automatically - NEVER define
idfield 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();Purpose: Manages database version tracking and executes schema files.
<?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
dbinfoclass checkDIR(__DIR__)scansdao/db/directory- Includes all PHP files found in
dao/db/ - Version tracked in
src/data/db_version.json - Only runs when
config::{module}_db_versionincrements
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;- Forstrings::rand(),strings::url(), etc.use bravedave\dvc\currentUser;- ForcurrentUser::methods (if not using\currentUser::)use bravedave\dvc\theme;- Fortheme::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 route4. Generate Unique IDs
<?php $_uid = strings::rand(); ?>
<div id="<?= $_uid ?>"></div>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>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>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>1. Unique ID Generation
<?= $_uid = strings::rand() ?> // Generate and output in same line
<div id="<?= $_uid ?>"></div> // Use the variable2. 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 itselfWhen 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.jsdoes this after append. - Do not use the native HTML
<dialog>element for edit forms; fetched edit views must use the Bootstrap 5.modalstructure shown above. - Matrix add/edit actions must open the edit view with
_.get.modal()and refresh the owning list after the modal emitssuccess.
_.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 eventFor 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 ?>">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
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); })
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
The DVC framework supports two distinct patterns for module controllers, each suited to different use cases and levels of entity complexity.
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
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
| 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) |
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
Simple CRUD:
- Row click:
$(this).trigger('edit')→ opens modal - No
view()controller method needed - No
view.phptemplate needed - Matrix is always visible
Rich CRUD Workbench:
- Row click:
$(this).trigger('view')→ opens workbench - Requires
view($id)controller method - Requires
view.phptemplate for tab content - Matrix collapses when workbench opens
- Use
_.tabs(element)to create dynamic tab system - Accordion structure with
data-bs-parentfor 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 buttonsUse this checklist when creating a new module:
- Create directory:
src/app/{module}/ - Create subdirectories:
dao/,dao/dto/,dao/db/,views/
- Create
config.phpextending root config - Define
{module}_db_versionconstant - Define
labelconstant - Create
{module}_checkdatabase()static method
- Create
dao/dbinfo.phpextending framework dbinfo - Create
dao/db/{entity}.phpwith schema definition - Define standard fields:
created,updated - Define entity-specific fields
- Add indexes for performance
- Create
dao/dto/{entity}.phpextending framework dto - Define public properties with default values
- Include standard fields:
id,created,updated - Create
dao/{entity}.phpextending framework dao - Set
$_db_nameand$templateproperties - Override
Insert()to add timestamps - Override
UpdateByID()to update timestamp - Add custom query methods (e.g.,
getMatrix())
- Create
controller.phpextending dvc controller - Implement
before()hook:- Call
config::{module}_checkdatabase() - Call
parent::before() - Register view path
- Call
- Implement
_index()for default view - Implement
postHandler()with match expression - Add public methods for other GET routes (e.g.,
edit())
- Create
handler.phpas 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()
- 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
- Create
src/controller/{module}.php - Define class extending module controller:
class {module} extends {namespace}\controller {} - Class name must match URL segment
- 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
- 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
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:
- Define database schema in
dao/db/ - Add DTO properties
- Implement handler methods
- Create view templates
- Add custom DAO methods
- 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)
- 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
- 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)
- 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)
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 routeWhen 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()orjson::nak()instead
Note: Response::redirect() terminates script execution. Code after the call will not run.
// 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');
}// 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// 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 contextsesc() 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()overhtmlspecialchars()andhtmlentities()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
// 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
}// 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!
// }// 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;
}// 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);
}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);
}
}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);
}
}// 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);
}// 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');
}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');
}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;
}- Check
config::{module}_db_versionis incremented - Verify
dao/dbinfo.phpcallscheckDIR(__DIR__) - Check
src/data/db_version.jsoncurrent version - Verify schema file has
$dbc->check()at end
- Verify action name matches in JavaScript and
postHandler() - Check handler method is static and public
- Ensure handler method returns
jsontype - Verify
ServerRequestspelling and usage
- Check DAO
$templateproperty points to correct DTO class - Verify DTO properties match database field names
- Ensure DTO extends framework
dtoclass
- Verify view path registered in
controller::before() - Check view file has namespace declaration
- Ensure view file exists in
views/directory
- Check unique IDs generated with
strings::rand() - Verify
_brayworth_framework loaded - Check console for JavaScript errors
- Ensure IIFE pattern wrapped correctly
- 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
# Create new module
vendor/bin/dvc make::module {name}
# Start dev server
vendor/bin/dvc serve
# Setup application
vendor/bin/dvc make::applicationCreate 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.