App creation checklist
This page is the single place that collects everything you must get right when you create a new Hubleto app — by hand, with the CLI or with an AI assistant. Each rule is explained, backed by code from the community apps (Customers, Deals, Invoices, Projects, PriceLists) and linked to the page that describes the topic in depth.
The rules are the same as in the prompt App creation constraints, which you can paste into an AI assistant before it generates your app. Read this page when you want to know why a rule exists and what it looks like in real code.
NOTE The rules describe how a new custom app (
Hubleto\App\Custom\...) should be written. The community apps were written over several years and do not follow every rule to the letter. Where a community app differs from a rule, the section says so (see also Where community apps differ from the rules). When rule and community code disagree, follow the rule.
Map: rule → section → page with details
| Rule from the constraints | Section on this page | Details |
|---|---|---|
| General rules, MVC, indentation | General rules | Introduction, Design principles |
| Namespaces, slugs, names of models, controllers, components | Naming conventions | Conventions, Namespaces |
init(), installApp(), sidebar, alerts, badge |
App's Loader class | Loader.php |
$lookupSqlValue, describe*(), relations, callbacks |
Models | Models, Description API |
prepareReadQuery(), addUrlFiltersToQuery(), prepareLookupData() |
Record managers | Models |
CRUD, API and board controllers, $this->translate() |
Controllers | API and cron controllers |
Twig views, translate() |
Views | Twig views |
Services folder |
Services | Services |
crud(), get(), api, board routes |
Routing | Routing |
Components/FC, tables, forms, Loader.tsx, FormCustomizer |
React UI components | React components, Loader.tsx |
| Event listeners | Event listeners | Event listeners |
| Crons | Crons | API and cron controllers |
| Workflow, Calendar, Dashboards, Settings, Extendibles | Integration with other apps | Integrations |
Documents\Generator, Notifications\Sender, Mail\Loader->send() |
Reuse of existing API | Reuse of existing APIs |
community-*-patches.md, hubleto-framework-patches.md |
Patches and improvements | Patch files |
Localization, demo data, CLI install, Settings, @var |
Miscellaneous | Translations, Installation |
Where each rule is explained.
General rules
- Read the community apps before you write anything. The documentation is derived from them and they are the final authority. Start with the smallest complete app (
Customers), then read the app closest to your use case (Dealsfor pipelines,Invoicesfor documents with items,Projectsfor hierarchical data,PriceListsfor the most recent code that follows these rules most strictly). - Follow the existing design patterns. Do not invent a new way to do something that an existing app already does.
- Indent with 2 spaces in PHP, TSX, Twig, YAML and JSON. The community code base does not use tabs.
- Hubleto apps follow MVC. The model describes data and UI, the controller prepares the view, and the view renders a React component that talks to the API.
How the MVC parts of an app work together
Browser PHP React (compiled from Loader.tsx)
------- --- --------------------------------
GET /customers/5 ──► Router (routes from Loader::init())
└► Controllers\Customers::prepareView()
└► Views/Customers.twig ──► <hblreact-customers-table-customers>
│
▼
TableCustomers.tsx ──► FormCustomer.tsx
│ api/table-describe-and-load
▼
Models\Customer::describeTable() + RecordManagers\Customer::loadTableData()
- Use `/ @var Class */
comments** whenever you create an object withgetService(),getModel()orgetController()`. The methods return interfaces, so without the comment IDEs cannot find the methods you call. Every snippet in this documentation does it:
apps/Deals/Controllers/Api/LogActivity.php (excerpt)
/** @var Deal */
$mDeal = $this->getModel(Deal::class);
$deal = $mDeal->record->find($idDeal)->first()?->toArray();
- Prefer codebase examples over CLI template examples.
php hubleto create app|model|mvcgenerates a minimal skeleton (see Command-line tool). The skeleton is a starting point. The community apps show what a finished app looks like (alerts, badges, demo data, filters, tabs, integrations).
Naming conventions
Summary
| What | Rule | Example |
|---|---|---|
| App namespace | Hubleto\App\Custom\AppName |
Hubleto\App\Custom\CarRental |
| App folder | src/apps/AppName (PascalCase, the same as the last part of the namespace) |
src/apps/CarRental |
URL slug (rootUrlSlug) |
kebab-case | price-lists, hr-leave, car-rental |
| Model | singular, PascalCase | Customer, Deal, Invoice, Project |
| Name of the main model | the same as the app (singular), only the main (header) model | app Deals → model Deal |
| Names of other models | do not repeat the app name unless they belong directly to the main model | Item, Profile, Payment (Invoices), LostReason (Deals) |
| Record manager | the same name as its model, in Models/RecordManagers |
Models\Deal → Models\RecordManagers\Deal |
| Migration | <Model>_<NNNN>.php in Models/Migrations |
Customer_0001.php, Customer_0002.php |
| Junction (cross) model | <Left>Has<Right> |
UserHasRole, PriceListHasDocument, ProductHasCategory |
| Lookup column | starts with id_ |
id_customer, id_owner, id_workflow_step |
| Relation name | UPPER_CASE | CUSTOMER, OWNER, WORKFLOW_STEP |
| CRUD controller | one per model; plural for a list of records | Controllers\Deals, Controllers\Tags |
| API controller | Controllers/Api/VerbNoun.php |
Api\LogActivity, Api\GenerateQuotationPdf |
| Board controller | Controllers/Boards/Name.php |
Boards\MostValuableDeals |
| Twig view | the same name as its controller | Views/Deals.twig, Views/Boards/MostValuableDeals.twig |
| React table component | Table + plural model name |
TableCustomers.tsx, TableItems.tsx |
| React form component | Form + singular model name |
FormCustomer.tsx, FormItem.tsx |
| Registered React component | AppName + table component name |
CustomersTableCustomers |
| HTML tag in a view | hblreact- + kebab-case of the registered name |
<hblreact-customers-table-customers> |
| Cron | Crons/Verb….php, extends Hubleto\Erp\Cron |
Crons\WarnAboutExpiry, Crons\SendMails |
| Event listener | EventListeners/Name.php |
EventListeners\InvalidateScopeCache |
Naming conventions.
Namespace, slug and manifest
The namespace of a custom app is always Hubleto\App\Custom\AppName. The class loader of Hubleto maps this namespace to src/apps/AppName/ of your project, so the app only needs to be unpacked into src/apps.
apps/Customers/manifest.yaml
apps/Customers/manifest.yaml
namespace: Hubleto\App\Community\Customers
appType: community
sidebarGroup: crm
rootUrlSlug: customers
name: Customers
icon: fas fa-address-card
author:
name: "Richard Ondrejka"
nick: "rindo"
company: "wai.blue"
github: "https://github.com/rindo789"
linkedin: "https://www.linkedin.com/in/richard-ondrejka/"
highlight: Free customer management and addressbook.
tags:
- customers
- contacts
For a custom app use namespace: Hubleto\App\Custom\AppName and appType: custom. The rootUrlSlug is the first part of every URL of the app, so keep it in kebab-case (price-lists). The rest of the file is described in manifest.yaml.
Models and record managers
Name models in the singular. Only the main (header) model has the same name as the app. All other models describe their own thing and must not repeat the app name:
| App | Models (singular) |
|---|---|
Invoices |
Invoice (main), Item, Payment, PaymentMethod, Profile |
Deals |
Deal (main), Item, LostReason, Tag, DealActivity, DealHistory, DealTag |
Projects |
Project (main), Milestone, MilestoneTask, Expense, ProjectRate |
PriceLists |
PriceList (main), Item, Assignment, Rule, History, Resolution |
Models of some community apps.
NOTE The community apps prefix the models that exist only for the main model with its name (
DealActivity,DealHistory,DealTag,CustomerTag,ProjectRate). Models that stand on their own (Tag,Item,LostReason) have no prefix. New apps should follow the stricter rule: use the name of the main model as a prefix only when a plain name would be ambiguous in the namespace, and never use the app name in the name of a record manager or React component.
Every model has a record manager with exactly the same name:
Models/Deal.php → class Deal
Models/RecordManagers/Deal.php → class Deal
Models/Migrations/Deal_0001.php → class Deal_0001
A junction (cross, many-to-many) model uses the Has pattern. The framework itself does it for users and roles:
apps/Auth/Models/UserHasRole.php
<?php
namespace Hubleto\App\Community\Auth\Models;
use Hubleto\Framework\Db\Column\Lookup;
class UserHasRole extends \Hubleto\Erp\Model
{
public string $table = 'user_has_roles';
public string $recordManagerClass = RecordManagers\UserHasRole::class;
public function describeColumns(): array
{
return array_merge(parent::describeColumns(), [
'id_user' => (new Lookup($this, $this->translate('User'), User::class))->setReactComponent('InputUserSelect'),
'id_role' => (new Lookup($this, $this->translate('Role'), UserRole::class)),
]);
}
public function describeTable(): \Hubleto\Framework\Description\Table
{
$description = parent::describeTable();
$description->ui['addButtonText'] = $this->translate('Assign Roles');
$description->hide(['header', 'fulltextSearch', 'columnSearch', 'moreActionsButton']);
$description->hide(['footer']);
return $description;
}
}
NOTE Older community code names the cross tables of tags
CustomerTag,DealTag(tablecross_customer_tags). Name new junction models<Left>Has<Right>, e.g.ProductHasCategory(tableproduct_has_categories, likeuser_has_rolesofUserHasRole).
Controllers: one controller and one view per model
Never combine several models into one controller and one view. Deals has Controllers\Deals + Views/Deals.twig for deals, Controllers\Tags + Views/Tags.twig for deal tags and Controllers\LostReasons + Views/LostReasons.twig for lost reasons.
React components
Table components are plural (TableCustomers.tsx), form components singular (FormCustomer.tsx), both in Components/FC. The tag in a Twig view is a kebab-case version of the name under which the component is registered in Loader.tsx:
Loader.tsx registers |
Twig view uses |
|---|---|
CustomersTableCustomers |
<hblreact-customers-table-customers> |
DealsTableDeals |
<hblreact-deals-table-deals> |
InvoicesTableItems |
<hblreact-invoices-table-items> |
Component name → tag name.
More in Using the hblreact HTML tag.
App's Loader class
The Loader class is the backend entry point of the app. A custom app always implements these five methods:
| Method | Purpose |
|---|---|
init() |
Registers routes, settings, calendars, workflows, boards, listeners and crons. |
installApp(int $round) |
Creates the tables of every model and the default data. |
renderSecondSidebar() |
Renders the app's menu (the second sidebar). |
renderAlerts() |
Renders warnings shown on the desktop (empty string if there is nothing to warn about). |
getSidebarBadgeNumber() |
Returns the number shown in the red badge next to the app in the main sidebar. |
Even if an app has nothing to show, create the method and return an empty string or 0. Then the next developer knows where to add the code, and the app behaves like the community apps.
NOTE In the community apps
renderAlerts()is implemented in 5 of 41 apps andgetSidebarBadgeNumber()in 14. The base class returns an empty string and0, so both are optional for Hubleto itself. The rule exists so that generated apps are complete.
Invoices implements all of them. This is the reference to copy from.
apps/Invoices/Loader.php (excerpt)
public function init(): void
{
parent::init();
$this->router()->get([
'/^invoices\/api\/get-customer-info\/?$/' => Controllers\Api\GetCustomerInfo::class,
'/^invoices\/api\/get-supplier-info\/?$/' => Controllers\Api\GetSupplierInfo::class,
'/^invoices\/api\/create-invoice-from-prepared-item\/?$/' => Controllers\Api\CreateInvoiceFromPreparedItem::class,
'/^invoices\/api\/link-prepared-item\/?$/' => Controllers\Api\LinkPreparedItem::class,
'/^invoices\/api\/unlink-prepared-item\/?$/' => Controllers\Api\UnlinkPreparedItem::class,
'/^invoices\/api\/generate-invoice-number\/?$/' => Controllers\Api\GenerateInvoiceNumber::class,
'/^invoices\/api\/send-invoice-in-email\/?$/' => Controllers\Api\SendInvoiceInEmail::class,
'/^invoices(\/(?<recordId>\d+))?\/?$/' => Controllers\Invoices::class,
'/^invoices\/add?\/?$/' => ['controller' => Controllers\Invoices::class, 'vars' => [ 'recordId' => -1 ]],
'/^invoices\/profiles(\/(?<recordId>\d+))?\/?$/' => Controllers\Profiles::class,
'/^invoices\/profiles\/add?\/?$/' => ['controller' => Controllers\Profiles::class, 'vars' => [ 'recordId' => -1 ]],
'/^invoices\/payments(\/(?<recordId>\d+))?\/?$/' => Controllers\Payments::class,
'/^invoices\/payments\/add?\/?$/' => ['controller' => Controllers\Payments::class, 'vars' => [ 'recordId' => -1 ]],
'/^invoices\/payment-methods(\/(?<recordId>\d+))?\/?$/' => Controllers\PaymentMethods::class,
'/^invoices\/payment-methods\/add?\/?$/' => ['controller' => Controllers\PaymentMethods::class, 'vars' => [ 'recordId' => -1 ]],
'/^invoices\/items(\/(?<recordId>\d+))?\/?$/' => Controllers\Items::class,
'/^invoices\/items\/add?\/?$/' => ['controller' => Controllers\Items::class, 'vars' => [ 'recordId' => -1 ]],
'/^invoices\/api\/create-invoice-from-order?\/?$/' => Controllers\Api\CreateInvoiceFromOrder::class,
]);
$this->addSearchSwitch('i', 'invoices');
/** @var \Hubleto\App\Community\Workflow\Manager $workflowManager */
$workflowManager = $this->getService(\Hubleto\App\Community\Workflow\Manager::class);
$workflowManager->addWorkflowGroup($this, 'invoices', Workflow::class);
}
installApp() is called three times (rounds 1–3). Create all tables in round 1 and call upgradeSchema() on every model of the app:
apps/Invoices/Loader.php (excerpt)
public function installApp(int $round): void
{
if ($round == 1) {
$this->getModel(Models\Payment::class)->upgradeSchema();
$this->getModel(Models\PaymentMethod::class)->upgradeSchema();
$this->getModel(Models\Invoice::class)->upgradeSchema();
$this->getModel(Models\Profile::class)->upgradeSchema();
$this->getModel(Models\Item::class)->upgradeSchema();
}
}
The three methods that feed the sidebar and the desktop use one shared service (Counter, see Services), so the numbers shown in the badge, in the alerts and in the sidebar buttons are always the same:
apps/Invoices/Loader.php (excerpt)
public function getSidebarBadgeNumber(): int
{
/** @var Counter */
$counter = $this->getService(Counter::class);
$preparedItemsCount = $counter->preparedItems();
// $notPaidInvoicesCount = $counter->notPaidInvoices();
$dueAndNotPaidInvoicesCount = $counter->dueAndNotPaidInvoices();
$unsentInvoicesCount = $counter->unsentInvoices();
return
$preparedItemsCount
+ $dueAndNotPaidInvoicesCount
+ $unsentInvoicesCount
;
}
apps/Invoices/Loader.php (excerpt)
public function renderSecondSidebar(): string
{
/** @var Counter */
$counter = $this->getService(Counter::class);
$preparedItemsCount = $counter->preparedItems();
$draftInvoicesCount = $counter->draftInvoices();
return '
' . $this->secondSidebarTitle() . '
<div class="app-sidebar-buttons">
' . $this->secondSidebarButton('invoices?filters%5BfDraft%5D=1', 'fas fa-pen-to-square', 'Drafts', $draftInvoicesCount) . '
' . $this->secondSidebarButton('invoices/items', 'fas fa-list', 'Items', $preparedItemsCount) . '
' . $this->secondSidebarButton('invoices/payments', 'fas fa-euro-sign', 'Payments') . '
' . $this->secondSidebarButton('invoices/payment-methods', 'fas fa-wallet', 'Payment methods') . '
' . $this->secondSidebarButton('invoices/profiles', 'fas fa-address-card', 'Invoicing profiles') . '
</div>
';
}
NOTE
secondSidebarButton($url, $icon, $title, $badge)translates$titleitself. Pass the English text and do not wrap it in$this->translate()again.
renderAlerts() returns HTML, usually a list of links to a filtered table. Show a line only when its counter is above zero:
apps/Invoices/Loader.php (excerpt)
public function renderAlerts(): string
{
/** @var Counter */
$counter = $this->getService(Counter::class);
// $preparedItemsCount = $counter->preparedItems();
$draftInvoicesCount = $counter->draftInvoices();
$notPaidInvoicesCount = $counter->notPaidInvoices();
$dueAndNotPaidInvoicesCount = $counter->dueAndNotPaidInvoices();
$unsentInvoicesCount = $counter->unsentInvoices();
return
($draftInvoicesCount > 0 ? '
<a href="' . $this->env()->projectUrl . '/invoices?filters%5BfDraft%5D=1">'
. $this->translate('Drafts') . ': ' . $draftInvoicesCount
. '</a><br/>
' : '')
. ($notPaidInvoicesCount > 0 ? '
<a href="' . $this->env()->projectUrl . '/invoices?filters%5BfIssued%5D=0&filters%5BfPaid%5D=2">'
. $this->translate('Not paid') . ': ' . $notPaidInvoicesCount
. '</a><br/>
' : '')
. ($dueAndNotPaidInvoicesCount > 0 ? '
<a href="' . $this->env()->projectUrl . '/invoices?filters%5BfIssued%5D=0&filters%5BfDue%5D=1&filters%5BfPaid%5D=2">'
. $this->translate('Due and not paid') . ': ' . $dueAndNotPaidInvoicesCount
. '</a><br/>
' : '')
. ($unsentInvoicesCount > 0 ? '
<a href="' . $this->env()->projectUrl . '/invoices?filters%5BfIssued%5D=0&filters%5BfSent%5D=2">'
. $this->translate('Not sent') . ': ' . $unsentInvoicesCount
. '</a><br/>
' : '')
;
}
The renderAlerts() and getSidebarBadgeNumber() methods run on the desktop for every user, so they must be fast: use count() queries, no loops over records. More in Loader.php, Second sidebar, Alerts and Sidebar badges.
Models
Every model describes data (columns, relations, indexes), UI (table, form, inputs) and behavior (callbacks). The base class is Hubleto\Erp\Model. See Models, record managers and migrations for the full anatomy.
Properties: $table, $recordManagerClass, $lookupSqlValue, $lookupUrlDetail
$lookupSqlValue is the SQL expression that is shown when a record is selected in a lookup input (a dropdown) and $lookupUrlDetail is the URL of the record's detail. Define both in every model, even in models that are used only as lookups. Without them a lookup shows an ID instead of a name and cannot link to the record.
apps/Deals/Models/Deal.php (excerpt)
public string $table = 'deals';
public string $recordManagerClass = RecordManagers\Deal::class;
public ?string $lookupSqlValue = 'concat(ifnull({%TABLE%}.identifier, ""), " ", ifnull({%TABLE%}.title, ""))';
public ?string $lookupUrlDetail = 'deals/{%ID%}';
{%TABLE%} is replaced by the table name (or its alias in a query) and {%ID%} by the ID of the record. A small model usually has a one-line expression:
apps/Customers/Models/Tag.php (excerpt)
public string $table = 'customer_tags';
public string $recordManagerClass = RecordManagers\Tag::class;
public ?string $lookupSqlValue = '{%TABLE%}.name';
Relations: BELONGS_TO for each lookup, HAS_MANY for children
Each Lookup column has a self::BELONGS_TO relation. Relation names are UPPER_CASE and each relation is implemented twice: in the model ($relations) and in the record manager (an Eloquent method with the same name).
apps/Deals/Models/Deal.php (excerpt)
public array $relations = [
'LEAD' => [ self::BELONGS_TO, Lead::class, 'id_lead', 'id'],
'CUSTOMER' => [ self::BELONGS_TO, Customer::class, 'id_customer', 'id' ],
'OWNER' => [ self::BELONGS_TO, User::class, 'id_owner', 'id'],
'MANAGER' => [ self::BELONGS_TO, User::class, 'id_manager', 'id'],
'CONTACT' => [ self::HAS_ONE, Contact::class, 'id', 'id_contact'],
'WORKFLOW' => [ self::HAS_ONE, Workflow::class, 'id', 'id_workflow'],
'WORKFLOW_STEP' => [ self::HAS_ONE, WorkflowStep::class, 'id', 'id_workflow_step'],
'CURRENCY' => [ self::HAS_ONE, Currency::class, 'id', 'id_currency'],
'TEMPLATE_QUOTATION' => [ self::HAS_ONE, Template::class, 'id', 'id_template_quotation'],
'TEMPLATE' => [ self::HAS_ONE, Template::class, 'id', 'id_template'],
'DOCUMENT' => [ self::HAS_ONE, Document::class, 'id', 'id_document'],
'HISTORY' => [ self::HAS_MANY, DealHistory::class, 'id_deal', 'id'],
'TAGS' => [ self::HAS_MANY, DealTag::class, 'id_deal', 'id' ],
'ITEMS' => [ self::HAS_MANY, Item::class, 'id_deal', 'id' ],
'ACTIVITIES' => [ self::HAS_MANY, DealActivity::class, 'id_deal', 'id' ],
'DOCUMENTS' => [ self::HAS_MANY, DealDocument::class, 'id_deal', 'id'],
'LEADS' => [ self::HAS_MANY, DealLead::class, 'id_deal', 'id'],
'TASKS' => [ self::HAS_MANY, DealTask::class, 'id_deal', 'id'],
// 'ORDERS' => [ self::HAS_MANY, OrderDeal::class, 'id_deal', 'id'],
];
Use HAS_MANY for the child records of the model (items, activities, tags, documents). The relations in the list above that use HAS_ONE (e.g. CURRENCY, WORKFLOW) point to a record by the other table's id; in new code prefer BELONGS_TO for every lookup column, as required by the rule.
Columns: describeColumns()
Always call parent::describeColumns() and merge your columns with it, so the primary key and the custom columns configured for the model are kept. The name of every Lookup column starts with id_.
apps/Deals/Models/Deal.php (excerpt)
'identifier' => (new Varchar($this, $this->translate('Deal Identifier')))->setCssClass('badge badge-info')->setDefaultVisible(),
apps/Deals/Models/Deal.php (excerpt)
'id_customer' => (new Lookup($this, $this->translate('Customer'), Customer::class))->setDefaultValue($this->router()->urlParamAsInteger('idCustomer')),
apps/Deals/Models/Deal.php (excerpt)
'id_owner' => (new Lookup($this, $this->translate('Owner'), User::class))->setReactComponent('InputUserSelect')->setDefaultValue($this->authProvider()->getUserId()),
Enumerations use an integer column with setEnumValues() and constants of the model. setEnumCssClasses() colors the value in tables:
apps/Deals/Models/Deal.php (excerpt)
'deal_result' => (new Integer($this, $this->translate('Deal Result')))
->setEnumValues(array_map(fn($v) => $this->translate($v), self::ENUM_DEAL_RESULTS))
->setEnumCssClasses([
self::RESULT_UNKNOWN => 'bg-yellow-100 text-yellow-800',
self::RESULT_WON => 'bg-green-100 text-green-800',
self::RESULT_LOST => 'bg-red-100 text-red-800',
])
->setDefaultValue(self::RESULT_UNKNOWN)
,
The most used setters are setRequired(), setReadonly(), setDefaultValue(), setDefaultVisible() (visible in the table by default), setCssClass(), setReactComponent(), setHint() and setProperty('sql', ...) for a virtual column calculated by SQL:
apps/Deals/Models/Deal.php (excerpt)
'virt_next_activity_date' => (new Virtual($this, $this->translate('Next activity')))->setDefaultVisible()
->setProperty('sql', "
select `a`.`date_start`
from `deal_activities` `a`
where
`a`.`completed` = 0
and `a`.`id_deal` = `deals`.`id`
and `a`.`date_start` >= date(now())
order by
`a`.`date_start` asc
limit 1
"),
More in describeColumns().
Table: describeTable()
describeTable() configures the table: texts, which parts of the UI are visible and the filters. Filters are added to the table description with addFilter(), not to a column. The first parameter is the name of the filter, which then arrives in the record manager as filters[<name>] (see addUrlFiltersToQuery() below).
apps/Customers/Models/Customer.php (excerpt)
public function describeTable(): Table
{
$description = parent::describeTable();
$description->ui['title'] = ''; //$this->translate('Customers');
$description->ui['addButtonText'] = $this->translate('Add Customer');
$description->show(['header', 'fulltextSearch', 'columnSearch', 'moreActionsButton']);
$description->hide(['footer']);
$description->addFilter('fCustomerActive', [
'title' => $this->translate('Active'),
'options' => [ 0 => $this->translate('Active'), 1 => $this->translate('Non-active') ],
// 'default' => 0,
]);
$fTagColors = [];
$fTagOptions = [];
foreach ($this->getModel(Tag::class)->record->get() as $value) {
$fTagColors[$value->id] = $value->color;
$fTagOptions[$value->id] = $value->name;
}
$description->addFilter('fTag', [
'title' => $this->translate('Tag'),
'type' => 'multipleSelectButtons',
'colors' => $fTagColors,
'options' => $fTagOptions,
]);
return $description;
}
NOTE The constraints prompt says "use
addFilter()indescribeColumns()".addFilter()is a method ofHubleto\Framework\Description\Table, so in code it is called indescribeTable(), exactly as above.Dealsfills theui['filters']array directly and adds more filters withaddFilter().
apps/Deals/Models/Deal.php (excerpt)
public function describeTable(): \Hubleto\Framework\Description\Table
{
$description = parent::describeTable();
$description->ui['addButtonText'] = $this->translate('Add Deal');
$description->show(['header', 'fulltextSearch', 'columnSearch', 'moreActionsButton']);
$description->hide(['footer']);
$description->ui['filters'] = [
'fDealClosed' => [
'direction' => 'horizontal',
'options' => [
1 => $this->translate('Open'),
2 => $this->translate('Closed'),
3 => $this->translate('All'),
],
'default' => 1,
],
'fDealWorkflowStep' => Workflow::buildTableFilterForWorkflowSteps($this, $this->translate('State')),
'fDealSourceChannel' => [ 'title' => $this->translate('Source channel'), 'type' => 'multipleSelectButtons', 'options' => array_map(fn($v) => $this->translate($v), self::ENUM_SOURCE_CHANNELS) ],
'fDealOwnership' => [ 'title' => $this->translate('Ownership'), 'options' => [ 0 => $this->translate('All'), 1 => $this->translate('Owned by me'), 2 => $this->translate('Managed by me') ] ],
];
$description->addFilter('fDealWithPlan', [
'title' => $this->translate('Planning'),
'options' => [
1 => $this->translate('With plan'),
2 => $this->translate('Without plan')
]
]);
return $description;
}
Two methods control how much data the table and the form load. Load only the relations that the table really shows:
apps/Deals/Models/Deal.php (excerpt)
public function getRelationsIncludedInLoadTableData(): array|null
{
return ['OWNER', 'MANAGER', 'CUSTOMER', 'WORKFLOW_STEP'];
}
apps/Deals/Models/Deal.php (excerpt)
public function getMaxReadLevelForLoadTableData(): int
{
return 2;
}
More in describeTable().
Form: describeForm()
Create describeForm() in every model. In the simplest case it calls the parent. Use it to set default values that cannot be a constant, e.g. the default workflow and currency of a new deal:
apps/Deals/Models/Deal.php (excerpt)
public function describeForm(): \Hubleto\Framework\Description\Form
{
$description = parent::describeForm();
/** @var Workflow */
$mWorkflow = $this->getModel(Workflow::class);
$description->defaultValues = $mWorkflow->applyDefaultWorkflow([], 'deals');
/** @var CurrencyService */
$currencyService = $this->getService(CurrencyService::class);
$description->defaultValues["id_currency"] = $currencyService->getDefaultCurrencyId();
return $description;
}
More in describeForm() and describeInput().
Callbacks: onBefore* and onAfter*
Use the callbacks to react to saving and deleting a record: onBeforeValidate, onAfterValidate, onBeforeCreate, onAfterCreate, onBeforeUpdate, onAfterUpdate, onBeforeDelete, onAfterDelete. They take and return the record (onBeforeDelete() and onAfterDelete() take and return the int $id).
Every callback must call parent:: — either first ($record = parent::onBeforeCreate($record); ... return $record;) or last (return parent::onBeforeCreate($record);). It is not a style rule. The parent:: method fires the onModelBeforeCreate, onModelAfterUpdate, ... events that event listeners of other apps depend on (audit logs, notifications, price tracking). A callback without parent:: silently switches them off for your model. parent::onAfterCreate() and parent::onAfterUpdate() also invalidate the lookup cache of the model.
Parent first — when you work with the result of the parent:
apps/Deals/Models/Deal.php (excerpt)
public function onAfterCreate(array $savedRecord): array
{
$savedRecord = parent::onAfterCreate($savedRecord);
$mDealHistory = $this->getService(DealHistory::class);
$mDealHistory->record->recordCreate([
"change_date" => date("Y-m-d"),
"id_deal" => $savedRecord["id"],
"description" => $this->translate('Deal created')
]);
if (empty($savedRecord['identifier'])) {
$identifier = $this->config()->forApp(DealsApp::class)->getAsString('numberingPattern', 'D{YY}-{#}');
$identifier = str_replace('{YYYY}', date('Y'), $identifier);
$identifier = str_replace('{YY}', date('y'), $identifier);
$identifier = str_replace('{MM}', date('m'), $identifier);
$identifier = str_replace('{DD}', date('d'), $identifier);
$identifier = str_replace('{#}', $savedRecord['id'], $identifier);
$savedRecord["identifier"] = $identifier;
$this->record->recordUpdate($savedRecord);
}
return $savedRecord;
}
Parent last — when you only prepare the record before it is saved:
apps/Deals/Models/Deal.php (excerpt)
public function onBeforeUpdate(array $record): array
{
$oldRecord = $this->record->find($record["id"])->toArray();
$mDealHistory = $this->getService(DealHistory::class);
$diff = $this->diffRecords($oldRecord, $record);
// ...
return parent::onBeforeUpdate($record);
}
Call callbacks of the model for what belongs to the model. Do not put code that belongs to another app here; register an event listener in that app instead.
Never create DTOs
Models, records and API responses are arrays. Do not create data transfer objects (DTO classes) for records.
NOTE The
Invoicesapp containsModels/Dto/Invoice.php. It is legacy code and a bad example. Pass records around as arrays ($record->toArray()).
Other useful methods
| Method | Purpose | Example |
|---|---|---|
indexes() |
Unique and other indexes of the table. | Customer |
getNewRecordDataFromString($text) |
Data of a record created from text typed into a lookup input ("create on the fly"). | Customer |
getLookupDetails($dataRaw) |
Second line shown under the name in a lookup. | Deal |
getAiAssistantContext($level, $id) |
Data that the AI assistant may read about the record. | Customer |
getDocumentDefaultTemplate($vars) |
Default Twig template for PDF documents of the record. | Deal |
Other methods of a model.
Record managers
The record manager does the real work with the database (Eloquent). Create these three methods in every record manager, even if they only call the parent. Then the next developer finds the place where to extend them, and the behavior stays the same as in the community apps.
| Method | Purpose |
|---|---|
prepareReadQuery() |
Base query used for every read (tables, forms, lookups, boards). Joins and virtual selects belong here. |
addUrlFiltersToQuery() |
Applies the URL parameters (filters[...], idCustomer, ...) to the query. |
prepareLookupData() |
Converts the loaded records into the data for the lookup input. |
The minimal (and valid) form:
<?php
namespace Hubleto\App\Custom\CarRental\Models\RecordManagers;
class Car extends \Hubleto\Erp\RecordManager
{
public $table = 'cars';
public function prepareReadQuery(mixed $query = null, int $level = 0, array|null $includeRelations = null): mixed
{
return parent::prepareReadQuery($query, $level, $includeRelations);
}
public function addUrlFiltersToQuery(mixed $query): mixed
{
return parent::addUrlFiltersToQuery($query);
}
public function prepareLookupData(array $dataRaw): array
{
return parent::prepareLookupData($dataRaw);
}
}
prepareReadQuery() of Hubleto\Erp\RecordManager already restricts the records to those a user may see when the table has id_owner, id_manager, id_team or shared_with (see Permissions). Always call the parent and extend its result. Never replace the query, or you would bypass the permissions.
apps/Customers/Models/RecordManagers/Customer.php (excerpt)
public function prepareReadQuery(mixed $query = null, int $level = 0, array|null $includeRelations = null): mixed
{
$query = parent::prepareReadQuery($query, $level, $includeRelations);
// Virtual tag count
$query->selectSub(function($sub) {
$sub->from('cross_customer_tags')
->join('customer_tags', 'customer_tags.id', '=', 'cross_customer_tags.id_tag')
->whereColumn('cross_customer_tags.id_customer', 'customers.id')
->selectRaw("COUNT(DISTINCT customer_tags.id)");
}, 'tags_count');
return $query;
}
addUrlFiltersToQuery() is the counterpart of addFilter() in describeTable():
apps/Deals/Models/RecordManagers/Deal.php (excerpt)
public function addUrlFiltersToQuery(mixed $query): mixed
{
$query = parent::addUrlFiltersToQuery($query);
$hubleto = \Hubleto\Erp\Loader::getGlobalApp();
if ($hubleto->router()->urlParamAsInteger("idCustomer") > 0) {
$query = $query->where("deals.id_customer", $hubleto->router()->urlParamAsInteger("idCustomer"));
}
$filters = $hubleto->router()->urlParamAsArray("filters");
$query = Workflow::applyWorkflowStepFilter(
$this->model,
$query,
(array) ($filters['fDealWorkflowStep'] ?? [])
);
if (isset($filters["fDealSourceChannel"]) && $filters["fDealSourceChannel"] > 0) {
$query = $query->where("deals.source_channel", $filters["fDealSourceChannel"]);
}
if (isset($filters["fDealResult"]) && $filters["fDealResult"] > 0) {
$query = $query->where("deals.deal_result", $filters["fDealResult"]);
}
if (isset($filters["fDealBusinessType"]) && $filters["fDealBusinessType"] > 0) {
$query = $query->where("deals.business_type", $filters["fDealBusinessType"]);
}
if (isset($filters["fDealBusinessType"]) && $filters["fDealBusinessType"] > 0) {
$query = $query->where("deals.business_type", $filters["fDealBusinessType"]);
}
if (isset($filters["fDealOwnership"])) {
switch ($filters["fDealOwnership"]) {
case 1: $query = $query->where("deals.id_owner", $hubleto->authProvider()->getUserId());
break;
case 2: $query = $query->where("deals.id_manager", $hubleto->authProvider()->getUserId());
break;
}
}
$fDealClosed = $filters['fDealClosed'] ?? 1;
if ($fDealClosed == 1) $query = $query->where("deals.is_closed", false);
if ($fDealClosed == 2) $query = $query->where("deals.is_closed", true);
prepareLookupData() adds data to each lookup item, e.g. the URL of the detail:
apps/Contacts/Models/RecordManagers/Contact.php (excerpt)
public function prepareLookupData(array $dataRaw): array
{
$data = parent::prepareLookupData($dataRaw);
foreach ($dataRaw as $key => $value) {
$data[$key]['_URL_DETAILS'] = 'contacts/' . $value['id'];
}
return $data;
}
The relations of the model are implemented in the record manager as Eloquent relations with the same UPPER_CASE name:
apps/Customers/Models/RecordManagers/Customer.php (excerpt)
/** @return BelongsTo<User, covariant Customer> */
public function OWNER(): BelongsTo
{
return $this->belongsTo(User::class, 'id_owner', 'id');
}
apps/Customers/Models/RecordManagers/Customer.php (excerpt)
/** @return HasMany<Contact, covariant Customer> */
public function CONTACTS(): HasMany
{
return $this->hasMany(Contact::class, 'id_customer');
}
Controllers
CRUD controllers
Put all CRUD controllers directly in Controllers/ (no subfolder), one controller for each model. A CRUD controller has only a breadcrumb and prepareView(); the table and the form are React components.
apps/Customers/Controllers/Customers.php
<?php
namespace Hubleto\App\Community\Customers\Controllers;
class Customers extends \Hubleto\Erp\Controller
{
public function prepareView(): void
{
parent::prepareView();
$this->setView('@Hubleto:App:Community:Customers/Customers.twig');
}
}
A controller of a settings-like model adds the breadcrumbs back to the settings:
apps/Deals/Controllers/Tags.php
<?php
namespace Hubleto\App\Community\Deals\Controllers;
class Tags extends \Hubleto\Erp\Controller
{
public function getBreadcrumbs(): array
{
return array_merge(parent::getBreadcrumbs(), [
[ 'url' => 'settings', 'content' => $this->translate('Settings') ],
[ 'url' => '', 'content' => $this->translate('Deal Tags') ],
]);
}
public function prepareView(): void
{
parent::prepareView();
$this->setView('@Hubleto:App:Community:Deals/Tags.twig');
}
}
API controllers in Controllers/Api
An API controller returns JSON, extends Hubleto\Erp\Controllers\ApiController and implements renderJson(). The base class handles errors (HTTP 400 with status: error) and is permitted for all signed-in users.
apps/Deals/Controllers/Api/LogActivity.php
<?php
namespace Hubleto\App\Community\Deals\Controllers\Api;
use Hubleto\App\Community\Deals\Models\Deal;
use Hubleto\App\Community\Deals\Models\DealActivity;
class LogActivity extends \Hubleto\Erp\Controllers\ApiController
{
public function renderJson(): array
{
$idDeal = $this->router()->urlParamAsInteger("idDeal");
$activity = $this->router()->urlParamAsString("activity");
if ($idDeal > 0 && $activity != '') {
/** @var Deal */
$mDeal = $this->getModel(Deal::class);
$deal = $mDeal->record->find($idDeal)->first()?->toArray();
if ($deal && $deal['id'] > 0) {
/** @var DealActivity */
$mDealActivity = $this->getModel(DealActivity::class);
$mDealActivity->record->recordCreate([
'id_deal' => $idDeal,
'subject' => $activity,
'date_start' => date('Y-m-d'),
'time_start' => date('H:i:s'),
'all_day' => true,
'completed' => true,
'id_owner' => $this->authProvider()->getUserId(),
]);
}
}
return [
"status" => "success",
"idDeal" => $idDeal,
];
}
}
Dashboard (board) controllers in Controllers/Boards
A board controller renders a small panel for the dashboard. It sets $hideDefaultDesktop = true so that the panel is rendered without the desktop around it.
apps/Deals/Controllers/Boards/MostValuableDeals.php
<?php
namespace Hubleto\App\Community\Deals\Controllers\Boards;
use Hubleto\App\Community\Deals\Models\Deal;
class MostValuableDeals extends \Hubleto\Erp\Controller
{
public bool $hideDefaultDesktop = true;
public function prepareView(): void
{
parent::prepareView();
/** @var Deal */
$mDeal = $this->getModel(Deal::class);
$mostValuableDeals = $mDeal->record->prepareReadQuery()
->with('CURRENCY')
->orderBy("price", "desc")
->offset(0)
->limit(5)
->get()
->toArray()
;
$this->viewParams['mostValuableDeals'] = $mostValuableDeals;
$this->setView('@Hubleto:App:Community:Deals/Boards/MostValuableDeals.twig');
}
}
Translate everything
Every string shown on screen goes through $this->translate(). This covers breadcrumbs, messages, titles of boards and error messages returned by API controllers:
return ['status' => 'error', 'message' => $this->translate('Price list was not found.')];
More in Translating PHP classes.
Views
A view is a Twig file with the same name as its controller. It does almost nothing: it renders the React component and passes it the state from the URL. Follow the structure, CSS classes and attributes of the community views:
apps/Customers/Views/Customers.twig
<hblreact-customers-table-customers
string:tag="table-customers"
int:record-id="{{ viewParams.recordId }}"
string:fulltext-search='{{ viewParams.q }}'
json:column-search='{{ viewParams.search|json_encode }}'
json:filters='{{ viewParams.filters|json_encode }}'
string:form-active-tab-uid='{{ viewParams.tab }}'
></hblreact-customers-table-customers>
A model with a plain table (tags, categories, lost reasons) needs no component of its own; use the generic hblreact-table with the model attribute:
apps/Deals/Views/Tags.twig
<hblreact-table
int:record-id="{{ viewParams.recordId }}"
string:model="Hubleto/App/Community/Deals/Models/Tag"
></hblreact-table>
Views that render HTML (boards, settings) use the CSS classes of Hubleto (app-main-title, table-default, btn, badge, alert). Translate every text with translate():
apps/Deals/Views/Boards/MostValuableDeals.twig
{% if viewParams.mostValuableDeals|length > 0 %}
<table class="table-default dense w-full">
{% for deal in viewParams.mostValuableDeals %}
<tr>
<td>
{{ deal.identifier }}
{{ deal.title }}
<small>{{ deal.CUSTOMER.name }}</small>
</td>
<td>{{ hubleto.locale().formatCurrency(deal.price_excl_vat ?? 0, deal.CURRENCY.code ?? '') }}</td>
<td class="text-right">
<a href="deals/{{ deal.id }}" class="btn btn-transparent btn-small">
<span class="icon"><i class="fas fa-arrow-right"></i></span>
<span class="text">{{ translate('Open deal') }}</span>
</a>
</tr>
{% endfor %}
</table>
{% else %}
<div class="alert alert-info">
{{ translate('No deals found.') }}<br/>
<br/>
<a href="deals?add" class="btn btn-transparent">
<span class="icon"><i class="fas fa-add"></i></span>
<span class="text">{{ translate('Add deal') }}</span>
</a>
</div>
{% endif %}
More in Twig views.
Services
Put reusable business logic (counters, calculators, adapters, converters) into classes in the Services/ folder of the app. A service extends Hubleto\Erp\Core (so it has getModel(), getService(), config(), translate(), ...) and is created with getService(). The same service object is then used from the Loader (badge, alerts, sidebar), crons, boards and API controllers.
apps/PriceLists/Services/Counter.php (excerpt)
class Counter extends \Hubleto\Erp\Core
{
/** Price lists waiting for activation. */
public function draftPriceLists(): int
apps/PriceLists/Loader.php (excerpt)
public function getSidebarBadgeNumber(): int
{
/** @var Counter $counter */
$counter = $this->getService(Counter::class);
return $counter->draftPriceLists() + count($counter->expiringPriceLists());
}
Do not put business logic into controllers or the Loader. More in Services.
NOTE Older community apps keep their helper classes in the root of the app (
Deals/Counter.php,Invoices/Counter.php).PriceLists,Invoices(PriceCalculator),ProductsandSettingsuseServices/. New apps useServices/.
Routing
CRUD routes with $router->crud()
Register the routes of each model with crud(). It creates the list/detail route (slug, slug/<id>) and the "add" route (slug/add) for you:
apps/PriceLists/Loader.php (excerpt)
$this->router()->crud('price-lists', Controllers\PriceLists::class);
$this->router()->crud('price-lists/items', Controllers\Items::class);
$this->router()->crud('price-lists/assignments', Controllers\Assignments::class);
$this->router()->crud('price-lists/rules', Controllers\Rules::class);
$this->router()->crud('price-lists/histories', Controllers\Histories::class);
$this->router()->crud('price-lists/resolutions', Controllers\Resolutions::class);
$this->router()->crud('price-lists/documents', Controllers\PriceListHasDocuments::class);
NOTE The router tests all routes and the last matching route wins. Start every regular expression with
^, end it with$and begin it with the slug of your app, so that the routes of different apps never collide.
API routes with $router->get()
API controllers (and any other non-CRUD controller) are registered with get(). The route starts with the slug of the app, followed by api:
apps/Invoices/Loader.php (excerpt)
'/^invoices\/api\/get-customer-info\/?$/' => Controllers\Api\GetCustomerInfo::class,
'/^invoices\/api\/get-supplier-info\/?$/' => Controllers\Api\GetSupplierInfo::class,
'/^invoices\/api\/create-invoice-from-prepared-item\/?$/' => Controllers\Api\CreateInvoiceFromPreparedItem::class,
'/^invoices\/api\/link-prepared-item\/?$/' => Controllers\Api\LinkPreparedItem::class,
'/^invoices\/api\/unlink-prepared-item\/?$/' => Controllers\Api\UnlinkPreparedItem::class,
'/^invoices\/api\/generate-invoice-number\/?$/' => Controllers\Api\GenerateInvoiceNumber::class,
'/^invoices\/api\/send-invoice-in-email\/?$/' => Controllers\Api\SendInvoiceInEmail::class,
Board routes
Routes of dashboard controllers start with the slug of the app followed by board and the name of the board in kebab-case:
apps/PriceLists/Loader.php (excerpt)
'/^price-lists\/board\/expiring\/?$/' => Controllers\Boards\ExpiringPriceLists::class,
'/^price-lists\/board\/margin-warnings\/?$/' => Controllers\Boards\MarginWarnings::class,
NOTE
Deals,LeadsandWorksheetsuseboards(plural) in the URL (deals/boards/most-valuable-deals). The rule for new apps isboard. The folder of the controllers isControllers/Boardsin both cases.
More in CRUD routes and other routes.
React UI components
Folder, file names and one component per model
- Put all components in
Components/FC(FC = function components). - Create a separate
Tableand a separateFormcomponent for each model. Never combine several models in one component. - Table components are plural (
TableCustomers.tsx), form components are singular (FormCustomer.tsx). - Do not use the app name in the name of a component file (
TableCustomers, notTableCustomersCustomers). The app name is added only when the component is registered inLoader.tsx. - Translate every string with
T.translate().
A table component only says which model it shows and which form it opens:
apps/Customers/Components/FC/TableCustomers.tsx
import React from 'react'
import Translator from '@hubleto/react-ui/core/Translator';
import Table from '@hubleto/react-ui/components/fc/Table';
import { type TableMeta, type TableProps } from '@hubleto/react-ui/components/fc/TableInterfaces';
import FormCustomer from './FormCustomer';
interface TableCustomersProps extends TableProps {}
const componentName = 'TableCustomers'; // must be the same as the exported const
const parentApp = 'Hubleto/App/Community/Customers';
const T = new Translator(parentApp + '/Loader', 'Components/' + componentName);
const TableCustomers = (props: TableCustomersProps) => {
return <Table
componentName={componentName}
parentApp={parentApp}
model={parentApp + '/Models/Customer'}
baseUrlSlug='customers'
formModalProps={{type: 'right wide'}}
renderCell={(table: TableMeta, columnName: string, column: any, data: any, options: any) => {
if (columnName == "virt_tags") {
return <div className='flex gap-1'>
{data.TAGS.map((tag, key) => {
return <div key={key} className="text-nowrap mr-2">
<i style={{color: tag.TAG?.color}} className="fas fa-tag mr-2"></i>
{tag.TAG?.name}
</div>;
})}
</div>;
} else {
return table.renderDefaultCell(columnName, column, data, options);
}
}}
renderForm={(table: TableMeta): React.JSX.Element => {
return <FormCustomer {...table.getDefaultFormProps()}/>;
}}
{...props}
></Table>
}
export default TableCustomers;
The form: tabs, customInputProps and cssClass
Use several tabs when the form has more than a dozen fields or contains related tables (contacts, activities, documents). customInputProps and cssClass format single inputs:
apps/Customers/Components/FC/FormCustomer.tsx (excerpt)
tabs={{
default: {title: <b>{T.translate('Customer')}</b>, content: () => <TabDefault {...props} />},
calendar: {title: T.translate('Calendar'), content: () => <TabCalendar {...props} />},
}}
apps/Customers/Components/FC/FormCustomer.tsx (excerpt)
<Input field='name' renderOnlyInputField customInputProps={{cssClass: 'text-[2em] border border-primary p-1 shadow rounded'}} />
// ...
<Input field='note' customInputProps={{cssClass: 'bg-yellow-50 dark:bg-slate-600'}} />
Loader.tsx
Loader.tsx registers the app and its components in the browser. Create it for every app that has React components, similar to the community apps. The key you register under is the namespace written with slashes:
apps/Customers/Loader.tsx
import App from '@hubleto/react-ui/core/App'
import CustomersTableCustomers from "./Components/FC/TableCustomers"
// import CustomersTableActivities from "./Components/TableActivities"
import CustomersFormActivity from "./Components/FC/CustomerFormActivity"
class CustomersApp extends App {
init() {
super.init();
// register react components
globalThis.hubleto.registerReactComponent('CustomersTableCustomers', CustomersTableCustomers);
// globalThis.hubleto.registerReactComponent('CustomersTableActivities', CustomersTableActivities);
globalThis.hubleto.registerReactComponent('CustomersCustomerFormActivity', CustomersFormActivity);
}
}
// register app
globalThis.hubleto.registerApp('Hubleto/App/Community/Customers', new CustomersApp());
More in Loader.tsx and React components.
FormCustomizer: inject your components into forms of other apps
An app must not edit the forms of other apps. Use FormCustomizer from @hubleto/react-ui/core/FormCustomizer instead. It lets you add a tab, a button in the header and a button in the footer of any form, identified by the name of the form component:
| Method | Adds |
|---|---|
FormCustomizer.addTab(form, tabUid, mount) |
a tab |
FormCustomizer.addFormHeaderExtraButton(form, mount) |
a button in the form header |
FormCustomizer.addFormFooterExtraButton(form, mount) |
a button in the form footer |
Methods of FormCustomizer.
mount is a function that receives the form (FormMeta) and returns either the thing to add or false (add nothing). Return false for a form that is not saved yet (form.id <= 0) if your content needs the ID of the record.
Projects adds a tab and a button to the form of an order:
apps/Projects/Loader.tsx (excerpt)
FormCustomizer.addTab(
'FormOrder',
'projects',
(form: FormMeta) => { return form.id <= 0 ? false : {
title: T.translate('Projects'),
content: (form: FormMeta) => {
return <TableProjects
tag={"table_project_order"}
parentForm={form}
//@ts-ignore
description={{ui: {showHeader:false}}}
descriptionSource='both'
uid={form.uid + "_table_project_order"}
junctionTitle={T.translate('Order')}
junctionModel='Hubleto/App/Community/Projects/Models/ProjectOrder'
junctionSourceColumn='id_order'
junctionSourceRecordId={form.id}
junctionDestinationColumn='id_project'
/>;
}
}}
);
// ...
FormCustomizer.addFormHeaderExtraButton(
'FormOrder',
(form: FormMeta) => { return form.id <= 0 ? false : {
title: T.translate('Create project'),
icon: 'fas fa-diagram-project',
onClick: (form: FormMeta) => {
request.get(
'projects/api/create-from-order',
{idOrder: form.id},
(data: any) => {
if (data.status == "success") {
globalThis.window.open(globalThis.hubleto.config.projectUrl + '/projects/' + data.idProject);
}
}
);
}
}}
);
The third kind, a button in the footer, has the same shape as the header button:
react-ui: core/FormCustomizer.tsx (excerpt)
static addFormFooterExtraButton(
componentName: string,
mount: (form: FormMeta) => boolean|FormExtraButton,
) {
if (!this.footerExtraButtons[componentName]) {
this.footerExtraButtons[componentName] = [];
}
this.footerExtraButtons[componentName].push({mount: mount});
}
NOTE The first parameter is the name of the form component (
FormOrder) of the app you extend, so your app depends on the name of that component. Check that it exists in the other app before you write the call.
Event listeners
An event listener is a class in EventListeners/ that is registered for an event in init(). A listener registered for onModelAfterUpdate (or any other onModel* event) is called for every model of the whole system, not only for yours. So the first line of the listener must be a guard: return unless $model is an instance of a class that you care about.
apps/PriceLists/EventListeners/InvalidateScopeCache.php
<?php
namespace Hubleto\App\Community\PriceLists\EventListeners;
use Hubleto\App\Community\Customers\Models\CustomerTag;
use Hubleto\App\Community\Products\Models\Category;
use Hubleto\App\Community\Products\Models\Group;
use Hubleto\App\Community\PriceLists\Services\PriceResolver;
use Hubleto\Framework\Interfaces\ModelInterface;
/**
* Clears cached categories, groups and customer tags of the price resolver when they change within a request.
*/
class InvalidateScopeCache extends \Hubleto\Framework\EventListener implements \Hubleto\Framework\Interfaces\EventListenerInterface
{
public function onModelAfterCreate(ModelInterface $model, array $savedRecord): void
{
if (!$this->isScopeModel($model)) return;
$this->clear();
}
public function onModelAfterUpdate(ModelInterface $model, array $originalRecord, array $savedRecord): void
{
if (!$this->isScopeModel($model)) return;
$this->clear();
}
public function onModelAfterDelete(ModelInterface $model, int $id): void
{
if (!$this->isScopeModel($model)) return;
$this->clear();
}
protected function isScopeModel(ModelInterface $model): bool
{
return $model instanceof Category || $model instanceof Group || $model instanceof CustomerTag;
}
protected function clear(): void
{
/** @var PriceResolver $resolver */
$resolver = $this->getService(PriceResolver::class);
$resolver->clearCache();
}
}
Register the listener in Loader::init() for each event it handles:
apps/PriceLists/Loader.php (excerpt)
/** @var EventListeners\InvalidateScopeCache $invalidateCache */
$invalidateCache = $this->getService(EventListeners\InvalidateScopeCache::class);
foreach (['onModelAfterCreate', 'onModelAfterUpdate', 'onModelAfterDelete'] as $event) {
$this->eventManager()->addEventListener($event, $invalidateCache);
}
The events and their arguments are listed in Event listeners.
Crons
- Put every cron in the
Crons/folder of the app. - Register it in
init()with$this->cronManager()->addCron(Crons\X::class). - A cron extends
Hubleto\Erp\Cron, sets$schedulingPattern(minute hour day month day-of-week) and implementsrun().
apps/PriceLists/Crons/WarnAboutExpiry.php
<?php
namespace Hubleto\App\Community\PriceLists\Crons;
use Hubleto\App\Community\Notifications\Sender;
use Hubleto\App\Community\PriceLists\Models\PriceList;
use Hubleto\App\Community\PriceLists\Services\Counter;
/**
* Notifies owners of price lists that are about to expire (within the setting expiryWarningDays).
* Notifications are sent with Notifications\Sender.
*/
class WarnAboutExpiry extends \Hubleto\Erp\Cron
{
public string $schedulingPattern = '0 7 * * *';
public function run(): void
{
/** @var Counter $counter */
$counter = $this->getService(Counter::class);
/** @var Sender $sender */
$sender = $this->getService(Sender::class);
foreach ($counter->expiringPriceLists() as $priceList) {
$idOwner = (int) ($priceList['id_owner'] ?? 0);
if ($idOwner <= 0) continue;
$sender->send(
900,
['price-lists', 'expiry'],
PriceList::class,
(int) $priceList['id'],
$idOwner,
$this->translate('Price list is about to expire'),
$priceList['code'] . ' ' . $priceList['name'] . ' - ' . $this->translate('valid to') . ' ' . $priceList['valid_to'],
'price-lists/' . $priceList['id']
);
}
}
}
apps/PriceLists/Loader.php (excerpt)
$this->cronManager()->addCron(Crons\ActivateAndExpire::class);
$this->cronManager()->addCron(Crons\WarnAboutExpiry::class);
}
/**
* Installs the app: creates tables (round 1) and seeds default data (round 2).
*
* @param int $round
*
* @return void
*
*/
public function installApp(int $round): void
{
if ($round == 1) {
$modelClasses = [
Models\PriceList::class, Models\Item::class,
Models\Assignment::class, Models\Rule::class, Models\History::class, Models\Resolution::class,
Models\PriceListHasDocument::class,
];
foreach ($modelClasses as $modelClass) {
/** @var \Hubleto\Erp\Model $model */
$model = $this->getModel($modelClass);
$model->upgradeSchema();
}
}
The cron runner is started by the system cron every minute (* * * * * php /path/to/project/cron.php). See Scheduled jobs for the scheduling syntax and its limits.
Integration with other apps
An app integrates with another app by calling its manager in init(). The manager is the public API of the other app. Always add the @var comment, otherwise the IDE does not know the methods.
| App to integrate with | Call in Loader::init() |
What you also need | Page |
|---|---|---|---|
Workflow |
$workflowManager->addWorkflowGroup($this, 'slug', Workflow::class) |
Workflow.php + id_workflow, id_workflow_step columns |
Workflow |
Calendar |
$calendarManager->addCalendar($this, 'slug', Calendar::class) |
Calendar.php + an activity model |
Calendar |
Dashboards |
$dashboardManager->addBoard($this, 'Title', 'slug/board/name') |
a board controller and view | Dashboards |
Settings |
$settingsApp->addSetting($this, [...]) |
a controller and view of the setting | Settings |
| Your own app | Extendible classes in Extendibles/ |
the collector in your Loader |
Extendibles |
Integrations.
PriceLists registers all four in one place:
apps/PriceLists/Loader.php (excerpt)
/** @var \Hubleto\App\Community\Settings\Loader $settingsApp */
$settingsApp = $this->appManager()->getApp(\Hubleto\App\Community\Settings\Loader::class);
$settingsApp->addSetting($this, [
'title' => $this->translate('Price lists'),
'icon' => 'fas fa-tags',
'url' => 'settings/price-lists',
]);
// ...
/** @var \Hubleto\App\Community\Workflow\Manager $workflowManager */
$workflowManager = $this->getService(\Hubleto\App\Community\Workflow\Manager::class);
$workflowManager->addWorkflowGroup($this, 'price-lists', Workflow::class);
// ...
/** @var \Hubleto\App\Community\Calendar\Manager $calendarManager */
$calendarManager = $this->getService(\Hubleto\App\Community\Calendar\Manager::class);
$calendarManager->addCalendar($this, 'price-lists', Calendar::class);
// ...
/** @var \Hubleto\App\Community\Dashboards\Manager $dashboardManager */
$dashboardManager = $this->getService(\Hubleto\App\Community\Dashboards\Manager::class);
$dashboardManager->addBoard($this, $this->translate('Expiring price lists'), 'price-lists/board/expiring');
NOTE Integrate with an app only if your app needs it. Then the first two lines of
init()stay free of dependencies and the app works whenCalendarorWorkfloware not enabled. The community apps call the managers directly because they are always installed; if you want to be defensive, test$this->appManager()->getApp(...)fornullfirst.
Extendibles
An extendible is a small class in Extendibles/ of an app that returns a list of items. Another app collects the items of all enabled apps with collectExtendibles('Name'). That is how Products finds the product types, Mail the template variables and Desktop the app menu. If you create your own extendible, provide examples of how another app uses it. See Extendibles.
Reuse of existing API
Do not re-implement what an existing app already does. Whenever possible, call:
| Need | Use | Example in the community code |
|---|---|---|
| Generate a document (PDF) | Hubleto\App\Community\Documents\Generator |
PriceLists\Controllers\Api\GeneratePdf, Deals\Controllers\Api\GenerateQuotationPdf |
| Create an internal notification | Hubleto\App\Community\Notifications\Sender |
PriceLists\Crons\WarnAboutExpiry |
| Send an e-mail | Hubleto\App\Community\Mail\Loader->send() (or queue a Mail record) |
PriceLists\Controllers\Api\SendByEmail, EmailMarketing\Controllers\Api\Launch |
| Currency, VAT, countries | models of Settings (Currency, VatRate, Country) |
Deals\Models\Deal |
| Customers, contacts, companies | models of Customers, Contacts, Settings\Company |
Deals\Models\Deal (CUSTOMER) |
| Tags, activities, documents | the same pattern as in Deals (Tag, DealActivity, DealDocument) |
Deals\Models |
What to reuse.
Examples of all three are on the page Reuse of existing APIs.
Community version patches and improvements
Sometimes your app needs a change in a community app, in the Hubleto core (hubleto/erp) or in the framework (hubleto/framework). Never modify those packages inside vendor/. Describe the change instead, so that it can be sent to the maintainers as a pull request or an issue:
| File | What goes inside |
|---|---|
community-apps-patches.md |
Patches or extensions needed in community apps (e.g. a new column in Deals, a new extendible). |
community-core-patches.md |
Improvements of the Hubleto core (Hubleto\Erp). |
hubleto-framework-patches.md |
Improvements of the framework (Hubleto\Framework). |
Patch files.
Create the files in the root of your app. Describe each item with the file, the problem, the proposed change and a code snippet. See Patch files for a template.
Miscellaneous
Localization
The app must be fully localizable. Translate everything that appears on screen: PHP with $this->translate(), Twig with translate() and TSX with T.translate(). Do not concatenate translated parts of a sentence. More in Translations and Localization.
Demo data in generateDemoData()
Always implement generateDemoData(). It creates sample records so that the app can be tried and screenshots can be made. Follow the community apps: create the records with recordCreate() from fixed sample values, so that every installation gets the same data.
apps/Invoices/Loader.php (excerpt)
public function generateDemoData(): void
{
/** @var Models\PaymentMethod */
$mPaymentMethod = $this->getModel(Models\PaymentMethod::class);
/** @var Models\Profile */
$mProfile = $this->getModel(Models\Profile::class);
$idPaymentMethod = $mPaymentMethod->record->recordCreate(['name' => $this->translate('bank transfer')])['id'];
$mProfile->record->recordCreate([
'name' => $this->translate('Test Profile 1'),
'numbering_pattern' => 'T/YYYYNNNN',
'is_default' => true,
'id_payment_method' => $idPaymentMethod,
'invoice_type_prefixes' => json_encode([
Models\Invoice::TYPES[Models\Invoice::TYPE_PROFORMA] => 'PRO',
Models\Invoice::TYPES[Models\Invoice::TYPE_ADVANCE] => 'ADV',
Models\Invoice::TYPES[Models\Invoice::TYPE_STANDARD] => 'INV',
Models\Invoice::TYPES[Models\Invoice::TYPE_CREDIT_NOTE] => 'CRD',
Models\Invoice::TYPES[Models\Invoice::TYPE_DEBIT_NOTE] => 'DBT',
]),
'mail_send_invoice_subject' => $this->translate('Invoice nr. {{ number }}'),
'mail_send_invoice_body' => $this->translate('Dear customer, see attached invoice nr. {{ number }}.'),
'mail_send_due_warning_subject' => $this->translate('Notification on due invoice nr. {{ number }}'),
'mail_send_due_warning_body' => $this->translate('Dear customer, this is a kindly reminder on due invoice nr. {{ number }}.'),
]);
}
More in Generate demo data.
Installable with php hubleto app install
Your app must install with one command from the root of the project, with no manual steps:
cd /path/to/your/project
php hubleto app install CarRental
npm run build
The command validates manifest.yaml, installs the apps listed in requires first, calls installApp(1) and marks the app as installed and enabled. That is why round 1 must create all tables. Everything that needs other apps' tables (foreign keys, default permissions) is created in rounds 2 and 3, which php hubleto init and the Settings app run (see Installing an app). Run php hubleto migrate afterwards to create the missing foreign keys.
A settings route, controller and view, and the Settings button
Every app has a page with its settings. Create the route, the controller and the view, and add the Settings button to renderSecondSidebar():
apps/Leads/Loader.php (excerpt)
'/^leads\/settings\/?$/' => Controllers\Settings::class,
apps/Leads/Controllers/Settings.php
<?php
namespace Hubleto\App\Community\Leads\Controllers;
class Settings extends \Hubleto\Erp\Controller
{
public function getBreadcrumbs(): array
{
return array_merge(parent::getBreadcrumbs(), [
[ 'url' => 'leads', 'content' => $this->translate('Leads') ],
[ 'url' => 'settings', 'content' => $this->translate('Settings') ],
]);
}
public function prepareView(): void
{
parent::prepareView();
$settingsChanged = $this->router()->urlParamAsBool('settingsChanged');
if ($settingsChanged) {
$calendarColor = $this->router()->urlParamAsString('calendarColor');
$leadsApp = $this->appManager()->getApp(\Hubleto\App\Community\Leads\Loader::class);
$leadsApp->setConfigAsString('calendarColor', $calendarColor);
$leadsApp->saveConfig('calendarColor', $calendarColor);
$leadPrefix = $this->router()->urlParamAsString('leadPrefix');
$leadsApp->setConfigAsString('leadPrefix', $leadPrefix);
$leadsApp->saveConfig('leadPrefix', $leadPrefix);
$this->viewParams['settingsSaved'] = true;
}
$this->setView('@Hubleto:App:Community:Leads/Settings.twig');
}
}
apps/Leads/Views/Settings.twig (excerpt)
<div class="flex">
<h1 class="app-main-title">{{ translate('Leads') }}: {{ translate('Settings') }}</h1>
{% if viewParams.settingsSaved %}
<div class="badge badge-info text-sm ml-4">{{ translate('Settings saved.') }}</div>
{# ... #}
<form id="settings-form" action="" method="POST" class="mt-2">
<input type="hidden" name="settingsChanged" value="1">
<table class="table-default">
<tbody>
<tr>
<td>{{ translate('Calendar color') }}</td>
<td>
<input
type="text" name="calendarColor"
class="hubleto component input"
value="{{ viewParams.app.configAsString('calendarColor') }}"
onchange="saveSettings();"
style="border-left: 2em solid {{ viewParams.app.configAsString('calendarColor') }}"
/>
</td>
</tr>
<tr>
<td>{{ translate('Lead Prefix') }}</td>
<td>
<input
type="text" name="leadPrefix"
class="hubleto component input"
value="{{ viewParams.app.configAsString('leadPrefix') }}"
onchange="saveSettings();"
/>
</td>
</tr>
</tbody>
</table>
</form>
The button in the second sidebar:
public function renderSecondSidebar(): string
{
return $this->secondSidebarTitle() . '
<div class="app-sidebar-buttons">
' . $this->secondSidebarButton('car-rental', 'fas fa-car', 'Cars') . '
' . $this->secondSidebarButton('car-rental/settings', 'fas fa-cog', 'Settings') . '
</div>
';
}
Also register the page in the Settings app with $settingsApp->addSetting($this, [ 'title' => $this->translate('Car rental'), 'icon' => 'fas fa-car', 'url' => 'car-rental/settings' ]) (see Settings integration). The values are stored with setConfigAsString() and saveConfig() and read with configAsString().
Folder structure of a complete app
src/apps/CarRental/
├── manifest.yaml
├── Loader.php init(), installApp(), renderSecondSidebar(), renderAlerts(), getSidebarBadgeNumber(), generateDemoData()
├── Loader.tsx registers the React components
├── Calendar.php (if integrated with Calendar)
├── Workflow.php (if integrated with Workflow)
├── community-apps-patches.md (only if needed)
├── community-core-patches.md (only if needed)
├── hubleto-framework-patches.md (only if needed)
├── Components/FC/
│ ├── TableCars.tsx
│ └── FormCar.tsx
├── Controllers/
│ ├── Cars.php one CRUD controller per model
│ ├── Settings.php
│ ├── Api/
│ │ └── MarkAsReturned.php
│ └── Boards/
│ └── CarsInService.php
├── Crons/
│ └── WarnAboutServiceDue.php
├── EventListeners/
│ └── ...
├── Extendibles/
│ └── ...
├── Models/
│ ├── Car.php
│ ├── Migrations/
│ │ └── Car_0001.php
│ └── RecordManagers/
│ └── Car.php
├── Services/
│ └── Counter.php
└── Views/
├── Cars.twig one view per controller
├── Settings.twig
└── Boards/
└── CarsInService.twig
The structure of a complete custom app.
Where community apps differ from the rules
The rules are intentionally stricter than the community code. Numbers are from the apps in hubleto/erp (41 apps, 151 models):
| Rule | Community apps |
|---|---|
installApp() in every Loader |
34 of 41 Loaders |
renderSecondSidebar() in every Loader |
23 of 41 |
renderAlerts() in every Loader |
5 of 41 |
getSidebarBadgeNumber() in every Loader |
14 of 41 |
generateDemoData() in every Loader |
15 of 41 |
describeTable() in every model |
135 of 151 |
describeForm() in every model |
47 of 151 (the parent's form is used otherwise) |
$lookupSqlValue and $lookupUrlDetail |
111 and 50 of 151 |
BELONGS_TO for every lookup |
95 models use it; some lookups use HAS_ONE (e.g. CURRENCY in Deal) |
prepareReadQuery() in every record manager |
58 of 151 |
addUrlFiltersToQuery() in every record manager |
22 of 151 |
prepareLookupData() in every record manager |
12 of 151 |
$router->crud() for each model |
17 of 41 apps; others register the same routes with get() |
Helper classes in Services/ |
5 apps; others keep Counter.php in the app root |
Board URL uses board |
only PriceLists; others use boards |
settings route, controller and view |
ai-assistant, leads, notifications and price-lists have a routed one; Deals has a controller and a view without a route |
| No DTOs | Invoices has Models/Dto/Invoice.php |
Differences between the rules and the community code.
Final self-review
Before you hand over the app, tick every line.
Structure and names
- [ ] Namespace
Hubleto\App\Custom\AppName, foldersrc/apps/AppName, kebab-caserootUrlSlug. - [ ] Models are singular; the record manager has the same name as the model; junctions use
Has. - [ ] One CRUD controller + one Twig view + one table component + one form component per model.
- [ ] Table components are plural, form components singular, all in
Components/FC.
Loader
- [ ]
init(),installApp(),renderSecondSidebar(),renderAlerts(),getSidebarBadgeNumber()andgenerateDemoData()exist. - [ ]
installApp(1)callsupgradeSchema()of every model. - [ ]
/** @var ... */before everygetService(),getModel()andgetController().
Models and record managers
- [ ]
$lookupSqlValueand$lookupUrlDetailin every model. - [ ]
describeColumns()(merged with the parent),describeTable()anddescribeForm()in every model. - [ ]
BELONGS_TOfor eachid_lookup column,HAS_MANYfor children; the same relations in the record manager. - [ ] Every
onBefore*/onAfter*callsparent::. - [ ]
prepareReadQuery(),addUrlFiltersToQuery()andprepareLookupData()in every record manager. - [ ] No DTO classes.
Controllers, views, routing
- [ ] CRUD controllers in
Controllers/, API inControllers/Api, boards inControllers/Boards. - [ ]
$router->crud()for each model,$router->get()for API and board routes with<slug>/api/...and<slug>/board/.... - [ ] All texts go through
$this->translate(),translate()andT.translate().
Integration and quality
- [ ] Listeners start with a guard on
$model; crons are inCrons/and registered withaddCron(). - [ ] Workflow / Calendar / Dashboards / Settings integration registered in
init()when used. - [ ] Documents, notifications and e-mails use
Generator,SenderandMail\Loader->send(). - [ ] A
settingsroute + controller + view + sidebar button exist. - [ ] Patches for other packages are described in
community-apps-patches.md,community-core-patches.mdandhubleto-framework-patches.md. - [ ]
php hubleto app install AppNamefollowed bynpm run buildworks on a clean project. - [ ] The app is delivered as a single
.zipthat is unpacked intosrc/apps.
