www.hubleto.eu Reddit User Guide Stack Overflow LinkedIn Report an issue

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

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()
apps/Deals/Controllers/Api/LogActivity.php (excerpt)
      /** @var Deal */
      $mDeal = $this->getModel(Deal::class);

      $deal = $mDeal->record->find($idDeal)->first()?->toArray();

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 (table cross_customer_tags). Name new junction models <Left>Has<Right>, e.g. ProductHasCategory (table product_has_categories, like user_has_roles of UserHasRole).

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 and getSidebarBadgeNumber() in 14. The base class returns an empty string and 0, 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 $title itself. 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() in describeColumns()". addFilter() is a method of Hubleto\Framework\Description\Table, so in code it is called in describeTable(), exactly as above. Deals fills the ui['filters'] array directly and adds more filters with addFilter().

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 Invoices app contains Models/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), Products and Settings use Services/. New apps use Services/.

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, Leads and Worksheets use boards (plural) in the URL (deals/boards/most-valuable-deals). The rule for new apps is board. The folder of the controllers is Controllers/Boards in both cases.

More in CRUD routes and other routes.

React UI components

Folder, file names and one component per model

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

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 when Calendar or Workflow are not enabled. The community apps call the managers directly because they are always installed; if you want to be defensive, test $this->appManager()->getApp(...) for null first.

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

Loader

Models and record managers

Controllers, views, routing

Integration and quality