On this page
- Build a complete contact manager
- 1. Create the module and its routes
- 2. Write and synchronize the complete DD
- 3. Define the complete form FD
- 4. Add the form template
- 5. Load, validate, and save in the service
- 6. Add the filter FD and dbxReport
- 7. Authorize delete operations on the server
- 8. Compare standard and Ajax requests
- 9. Add a contract test and accept the module
Build a complete contact manager
This tutorial takes you through the mandatory Golden Path: DD and schema synchronization, FD and dbxForm, dbxReport, server-side authorization, Ajax, and contract tests.
What you will have built
?dbx_modul=myContactsdisplays a searchable contact list.- New and existing contacts use the same FD-driven dbxForm.
- Save and delete operations go exclusively through dbxDB and a complete DD.
- Standard and Ajax requests share the same PHP code path.
myInvoices reference module in dbxapp 4.5.3. Work in a development installation, not directly in production.1. Create the module and its routes
Create the following structure. Keep routing and domain work separate; treat the DD and each FD as explicit contracts.
dbx/modules/myContacts/
├── cfg/config.php
├── dd/contact.dd.php
├── fd/contact-form.fd.php
├── fd/contact-report.fd.php
├── include/myContactsService.class.php
├── tpl/htm/contact-form.htm
├── tpl/htm/contact-report.htm
├── tests/myContactsContract_test.php
└── myContacts.class.php
myContacts.class.php
<?php
namespace dbx\myContacts;
class myContacts
{
public function run(): string
{
$run=(string)dbx()->get_modul_var('dbx_run1','report','parameter|max=32');
$service=dbx()->get_include_obj('myContactsService','myContacts');
return match($run){'form','edit'=>$service->form(),'delete'=>$service->delete(),default=>$service->report()};
}
}
?>
cfg/config.php
<?php
$config['version']='1.0.0';
$config['activ']='1';
$config['groups']='admin';
?>
Test now: Run php -l dbx/modules/myContacts/myContacts.class.php.
Expected: No syntax errors detected. The web route may still fail in a controlled way until the service exists.
2. Write and synchronize the complete DD
The DD defines the table, every field, and every index in a directly readable export format. Define each field explicitly with the complete attribute set; do not hide field definitions in a closure or factory.
<?php
$table=array(
'server'=>'myContacts|myContacts.db3','table'=>'contact','datadic'=>'contact',
'primary'=>'id','language'=>'0','version'=>'1.0','autosync'=>'1','cache'=>'0',
'trash'=>'0','trace'=>'0','update_sql'=>'','default_sort'=>'name ASC','form-dd-table'=>'',
'read'=>'admin','create'=>'admin','update'=>'admin','delete'=>'admin',
'read_owner'=>'admin,owner','create_owner'=>'admin,owner',
'update_owner'=>'admin,owner','delete_owner'=>'admin,owner'
);
$field['name']='id';$field['type']='int';$field['index']='PRI';$field['length']='11';$field['default']='';
$field['label']='ID';$field['rules']='int';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='hidden';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='create_date';$field['type']='datetime';$field['index']='MUL';$field['length']='-1';$field['default']='';
$field['label']='Created';$field['rules']='datetime';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='date_time';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='hidden';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='create_uid';$field['type']='int';$field['index']='MUL';$field['length']='11';$field['default']='0';
$field['label']='Created by';$field['rules']='int';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='hidden';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='update_date';$field['type']='datetime';$field['index']='MUL';$field['length']='-1';$field['default']='';
$field['label']='Updated';$field['rules']='datetime';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='date_time';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='hidden';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='update_uid';$field['type']='int';$field['index']='MUL';$field['length']='11';$field['default']='0';
$field['label']='Updated by';$field['rules']='int';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='hidden';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='owner';$field['type']='int';$field['index']='MUL';$field['length']='11';$field['default']='0';
$field['label']='Owner';$field['rules']='int';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='hidden';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='name';$field['type']='varchar';$field['index']='MUL';$field['length']='160';$field['default']='';
$field['label']='Name';$field['rules']='*|min=2|max=160';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='text-label';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='email';$field['type']='varchar';$field['index']='UNI';$field['length']='190';$field['default']='';
$field['label']='Email';$field['rules']='email|max=190';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='email-label';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='phone';$field['type']='varchar';$field['index']='';$field['length']='40';$field['default']='';
$field['label']='Phone';$field['rules']='text|max=40';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='';
$field['tpl']='text-label';$field['js']='';$field['prompt']='';$fields[]=$field;
$field['name']='status';$field['type']='varchar';$field['index']='MUL';$field['length']='16';$field['default']='active';
$field['label']='Status';$field['rules']='parameter|max=16';$field['tooltip']='';$field['errormsg']='';$field['placeholder']='';
$field['convert']='';$field['protect']='0';$field['group']='';$field['mask']='';$field['data']='';$field['options']='active=Active&blocked=Blocked';
$field['tpl']='select-single-label';$field['js']='';$field['prompt']='';$fields[]=$field;
$indexes=array(
array('name'=>'pk_contact','type'=>'PRIMARY','fields'=>'id','unique'=>'1','comment'=>'Primary key'),
array('name'=>'idx_contact_create_uid','type'=>'INDEX','fields'=>'create_uid','unique'=>'0','comment'=>'Created by'),
array('name'=>'idx_contact_update_uid','type'=>'INDEX','fields'=>'update_uid','unique'=>'0','comment'=>'Updated by'),
array('name'=>'idx_contact_owner','type'=>'INDEX','fields'=>'owner','unique'=>'0','comment'=>'Owner'),
array('name'=>'idx_contact_name','type'=>'INDEX','fields'=>'name','unique'=>'0','comment'=>'Name search'),
array('name'=>'uidx_contact_email','type'=>'UNIQUE','fields'=>'email','unique'=>'1','comment'=>'Unique email'),
array('name'=>'idx_contact_status','type'=>'INDEX','fields'=>'status','unique'=>'0','comment'=>'Status filter')
);
?>
Test now: Select myContacts|contact in DD synchronization and run it twice.
Expected: The first run creates the table and indexes. The second reports no schema changes, and the DD and database field counts match.
3. Define the complete form FD
fd/contact-form.fd.php
<?php
$messages=array('form_info'=>'Review and save the contact details.',
'form_title_new'=>'New contact','form_title_edit'=>'Edit contact',
'not_found'=>'Contact not found.','validation_error'=>'Please review your entries.');
$fields=array();
foreach(array(
array('name','varchar','text-label','Name','*|min=2|max=160',''),
array('email','varchar','email-label','Email','email|max=190',''),
array('phone','varchar','text-label','Phone','text|max=40',''),
array('status','varchar','select-single-label','Status','parameter|max=16','active=Active&blocked=Blocked')
)as $d){$fields[]=array('name'=>$d[0],'type'=>$d[1],'tpl'=>$d[2],'label'=>$d[3],
'rules'=>$d[4],'options'=>$d[5],'index'=>'','length'=>'','default'=>'',
'tooltip'=>'','errormsg'=>'','placeholder'=>'','convert'=>'','protect'=>'0','mask'=>'','data'=>'');}
?>
Result: Validation, labels, and status options come from the FD instead of being duplicated in the service.
4. Add the form template
tpl/htm/contact-form.htm
<div id="dbxForm_1" class="dbx-panel dbxForm_wrapper dbx-ajax-root">
<div class="dbx-panel-head"><h2 class="h5">{form_title}</h2>
<a href="{list_url}" class="btn btn-outline-secondary btn-sm">Back to list</a></div>
<form action="{action}" method="post" id="dbx_form_1" class="dbxAjax" data-target="dbxForm_1">
<div class="dbx-panel-body">{form:message}<div class="row g-3">[dbx:form]</div></div>
<div class="dbx-panel-foot"><button type="submit" class="btn btn-primary">Save</button></div>
</form>
</div>
Checkpoint: The form ID, Ajax target, and DOM ID belong together. Include {form:message} and [dbx:form] exactly once.
5. Load, validate, and save in the service
Start include/myContactsService.class.php with this complete form path:
<?php
namespace dbx\myContacts;
class myContactsService
{
private const DD='myContacts|contact';
private const FORM_FD='myContacts|contact-form';
private const REPORT_FD='myContacts|contact-report';
private function url(string $run,array $params=array()):string{
$url='?dbx_modul=myContacts&dbx_run1='.rawurlencode($run);
foreach($params as $key=>$value)$url.='&'.rawurlencode((string)$key).'='.rawurlencode((string)$value);
return $url;
}
public function form():string{
$ridValue=(string)dbx()->get_modul_var('rid','new','parameter|max=24');
$rid=$ridValue==='new'?0:(int)$ridValue;$isNew=$rid<=0;
$form=dbx()->get_system_obj('dbxForm');
$form->init('contact-form',self::FORM_FD);
$form->set_data_source(self::DD,self::FORM_FD);$form->load_fd_messages();
$data=$isNew?array('status'=>'active'):dbx()->get_system_obj('dbxDB')->select1(self::DD,array('id'=>$rid));
if(!$isNew&&(int)($data['id']??0)<=0)return dbx()->get_system_obj('dbxTPL')->get_tpl(
'dbx|alert-warning',array('msg'=>$form->get_fd_message('not_found')));
$form->set_data(is_array($data)?$data:array())->set_rid($rid)
->set_action($this->url('form',array('rid'=>$isNew?'new':$rid)));
$form->_msg_info=$form->get_fd_message('form_info');
$form->add_rep('form_title',$form->get_fd_message($isNew?'form_title_new':'form_title_edit'));
$form->add_rep('list_url',$this->url('report'));$form->add_flds();
if($form->submit()){if(!$form->errors())$form->save_post(self::DD,$isNew?'new':$rid);
else $form->_msg_error=$form->get_fd_message('validation_error');}
return $form->run();
}
Test now: Open ?dbx_modul=myContacts&dbx_run1=form&rid=new. Submit an empty name, then valid values.
Expected: The first submission stays on the form and shows FD validation. The second creates exactly one record. Reloading without another submit does not create a duplicate.
6. Add the filter FD and dbxReport
fd/contact-report.fd.php
<?php
$messages=array('report_title'=>'Contacts','filter_error'=>'Please review the filters.',
'permission_denied_action'=>'This action requires an administrator account.',
'invalid_contact'=>'The requested contact is invalid.',
'delete_success'=>'Contact deleted.','delete_error'=>'The contact could not be deleted.',
'delete_title'=>'Delete contact','delete_question'=>'Delete contact 211?',
'delete_hint'=>'This action cannot be undone.');
$fields=array(
array('name'=>'dbx_rwhere','type'=>'varchar','tpl'=>'dbx|search','default'=>'',
'label'=>'Search','rules'=>'sqlsearch|max=64'),
array('name'=>'dbx_rstatus','type'=>'varchar','tpl'=>'select-single-label','default'=>'all',
'label'=>'Status','rules'=>'parameter|max=16','options'=>'all=All&active=Active&blocked=Blocked'));
?>
Add the following methods before the service class closes:
public function report(string $successKey='',string $errorKey=''):string{
$db=dbx()->get_system_obj('dbxDB');$report=dbx()->get_system_obj('dbxReport');
$report->init('contact-report',self::REPORT_FD);
$report->set_data_definition(self::DD)->set_mode('table')->set_action($this->url('report'))
->set_pagination(true,20)->set_table_actions(array());
$report->create_selection_fields(self::REPORT_FD);
$report->_msg_success=$successKey===''?'':$report->get_fd_message($successKey,$successKey);
$report->_msg_error=$errorKey===''?'':$report->get_fd_message($errorKey,$errorKey);
$search=trim((string)$report->get_fld_val('dbx_rwhere','','sqlsearch|max=64'));
$status=(string)$report->get_fld_val('dbx_rstatus','all','parameter|max=16');$where=array();
if(in_array($status,array('active','blocked'),true))$where['status']=$status;
if($search!=='')$where['search']=array('value'=>$search,'like'=>array('name','email','phone'),'mode'=>'contains');
$rows=$db->select(self::DD,$where,array('id','name','email','phone','status'),'name','ASC','',20,0);
$report->_rflds=array('name'=>'Name','email'=>'Email','phone'=>'Phone','status'=>'Status','action'=>'Action');
$report->_rpt_format=array('action'=>'html');
$report->_rrows=20;$report->_rpos=0;$report->_count_all=$db->count(self::DD);
$report->_rcount=$db->count(self::DD,$where);$report->_rdata=is_array($rows)?$rows:array();
return $report->run();
}
public function contact_report_next_record($report,$record){
if(!is_array($record))return $record;
$rid=(int)($record['id']??0);
$record['action']=dbx()->get_system_obj('dbxTPL')->get_tpl(
'myContacts|contact-row-action',array(
'edit_url'=>$this->url('form',array('rid'=>$rid)),
'delete_url'=>dbx()->action_url($this->url('delete',array('rid'=>$rid))),
'delete_title'=>$report->get_fd_message('delete_title'),
'delete_question'=>$report->format_fd_message('delete_question',array('id'=>$rid)),
'delete_hint'=>$report->get_fd_message('delete_hint')));
$status=(string)($record['status']??'');
if($status!=='')$record['status']=$report->get_fd_message('status_'.$status,$status);
return $record;
}
tpl/htm/contact-report.htm
<div class="dbx-panel dbxReport dbx-ajax-root" id="dbx_target_1">
<div class="dbx-panel-head"><h2 class="h5">Contacts</h2>
<a class="btn btn-primary" href="?dbx_modul=myContacts&dbx_run1=form&rid=new">New</a></div>
<form action="{action}" method="post" id="dbx_form_1" class="dbxAjax">
{report:bar}{report:message}[dbx:pagination]
<table class="table table-striped"><thead><tr>[rpt:row]</tr></thead>
<tbody><hr class="dbx_split"><tr>[rpt:row]</tr><hr class="dbx_split"></tbody></table>
</form>
</div>
tpl/htm/contact-row-action.htm
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-outline-danger dbxAjax dbxConfirm" href="{delete_url}"
data-confirm-title="{delete_title}" data-confirm="{delete_question}"
data-confirm-hint="{delete_hint}" data-confirm-buttons="yesno">
<i class="bi bi-trash" aria-hidden="true"></i>
<span class="visually-hidden">Delete</span>
</a>
</div>
Test now: Create three contacts, search for part of a name, and change the status filter.
Expected: The overall total remains three while the result count follows the filter. Invalid status values never enter the DD query.
7. Authorize delete operations on the server
Generate the delete link with dbx()->action_url(). The central action policy validates the token, while group authorization remains mandatory on the server.
public function delete():string{
if(!dbx()->has_group('admin'))return $this->report('','permission_denied_action');
$rid=(int)dbx()->get_modul_var('rid',0,'int');
if($rid<=0)return $this->report('','invalid_contact');
$deleted=dbx()->get_system_obj('dbxDB')->delete(self::DD,array('id'=>$rid));
return $deleted===1?$this->report('delete_success'):$this->report('','delete_error');
}
}
Checkpoint: JavaScript may ask for confirmation, but it cannot replace the token or group check. Test valid, altered, and missing record IDs and a session without permission.
8. Compare standard and Ajax requests
dbxAjax and stable target IDs activate the existing JavaScript pipeline. There is no separate Ajax implementation of the domain operation.
Test now: Save and filter once with JavaScript enabled and once with it disabled.
Expected: The saved record and message are identical. Ajax replaces the target region; without JavaScript, the complete page reloads.
9. Add a contract test and accept the module
tests/myContactsContract_test.php
<?php
$root=dirname(__DIR__);$errors=array();
$required=array('myContacts.class.php','cfg/config.php','dd/contact.dd.php','fd/contact-form.fd.php',
'fd/contact-report.fd.php','include/myContactsService.class.php','tpl/htm/contact-form.htm','tpl/htm/contact-report.htm','tpl/htm/contact-row-action.htm');
foreach($required as $file)if(!is_file($root.'/'.$file))$errors[]='Missing: '.$file;
$php=file_get_contents($root.'/myContacts.class.php')."\n".file_get_contents($root.'/include/myContactsService.class.php');
foreach(array('/\bPDO\b/','/\bmysqli?_?/','/\bSQLite3\b/','/->(query|prepare|exec)\s*\(/')as $pattern)
if(preg_match($pattern,$php))$errors[]='Direct database access: '.$pattern;
foreach(array("get_system_obj('dbxDB')","get_system_obj('dbxForm')","get_system_obj('dbxReport')","has_group('admin')",'contact_report_next_record','action_url(','dbxConfirm','data-confirm-buttons="yesno"')as $needle)
if(strpos($php,$needle)===false)$errors[]='Missing contract: '.$needle;
if($errors){fwrite(STDERR,"FAIL\n- ".implode("\n- ",$errors)."\n");exit(1);}echo "OK myContacts architecture contract\n";
php -l dbx/modules/myContacts/myContacts.class.php
php -l dbx/modules/myContacts/include/myContactsService.class.php
php dbx/modules/myContacts/tests/myContactsContract_test.php
php dbx/modules/dbxSelfTest/tools/run.php --profile=full
Expected: Both syntax checks, the module contract, and the full SelfTest pass. Finish with keyboard, mobile, and authorization checks, then write one change-log entry for the completed logical change.