dbxapp Knowledge Build a contact manager: complete developer tutorial

Build a contact manager: complete developer tutorial

On this page
  1. Build a complete contact manager
    1. What you will have built
  2. 1. Create the module and its routes
  3. 2. Write and synchronize the complete DD
  4. 3. Define the complete form FD
  5. 4. Add the form template
  6. 5. Load, validate, and save in the service
  7. 6. Add the filter FD and dbxReport
  8. 7. Authorize delete operations on the server
  9. 8. Compare standard and Ajax requests
  10. 9. Add a contract test and accept the module
Developer tutorial · Copy & Build · about 90 minutes

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=myContacts displays 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.

Open the runnable module

The public demo is read-only. Creating, editing, and deleting contacts requires an administrator account.
Reference contractThe examples use the same APIs and contracts as the installable 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')
);
?>
Do not bypass the data layer. PDO, mysqli, SQLite3, and direct SQL are prohibited in fixtures and tests as well. Apply schema changes through DD synchronization.

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&amp;dbx_run1=form&amp;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.