Festi CLI

Festi CLI is the name of the command-line interface included with Festi Framework. It provides a number of helpful commands for your use while developing your application.

To use CLI tools, you need to install the festi-team/festi-framework-cli package:

{
    "require": {
      "festi-team/festi-framework-cli": "dev-develop"
    }
}

Getting Help

Every command answers --help (or -h) with its usage, options and examples, and ends with the list of all festi-* commands. Help works from any directory: it never prompts, reads config.php or connects to a database.

./vendor/bin/festi-migrate --help
./vendor/bin/festi-plugin -h

For a command with modes (festi-plugin, festi-react, festi-locale), add --mode to see that mode's options:

./vendor/bin/festi-plugin --help --mode exec

Verbose Output

Every command also accepts --verbose (or -v). A normal run shows only the progress messages; a verbose run adds debug lines: the shell commands run and their output, each SQL statement that changes the database, the project config and database driver used, the files written, the migrations skipped as already applied, and the stack trace when the command fails. Credentials are never printed -- they reach database clients through the environment, not the command.

./vendor/bin/festi-migrate --path ./src/admin/ -v
./vendor/bin/festi-install --verbose

--verbose=n (also false, 0, no) keeps it off, which is how the installer's --verbose y|n keeps working.

The SQL shown is what the tool ran through PHP: INSERT, UPDATE, DELETE and schema statements. Reads are not shown, and neither is a migration file that festi-migrate hands to the mysql or psql client.

Saving SQL

A table created with festi-dgs --type table, or a plugin or URL registered with festi-plugin, exists only in your own database. --schema saves the SQL of the run as a migration, so festi-migrate makes the same change everywhere else. festi-dgs, festi-dgs-exec and festi-plugin accept it.

./vendor/bin/festi-dgs --type table --name active_users --definition-path ./tblDefs/ --schema
./vendor/bin/festi-plugin --mode create --name Expenses --schema=./dumps/develop.sql
  • --schema alone creates <path>/dumps/updates<timestamp>.sql, named the same way as by festi-make-migration. The migration is recorded as applied to your database, because its SQL already ran there.
  • --schema=<file> appends the SQL to that file and keeps what the file holds. Write the value with =: --schema <file> is read as --schema alone. festi-migrate applies only files named updates<number>.sql. A working file such as develop.sql becomes a migration with festi-make-migration --develop.
  • Do not append to a migration that is already applied: the databases that applied it never run what was added. The tool warns when the file is one; use --schema alone for a new migration instead.
  • Nothing is saved when the run fails or changes nothing.
  • The saved SQL can be replayed on another database. A row that refers to another one finds it by what it says, not by the id your database gave it: the area of a URL rule is linked to the rule by its plugin, pattern and method.
  • --schema needs a festi-team/festi-framework-database that reports its statements (IQueryListeners). With an older one the command stops before it changes anything and asks you to update the package.

Installer

The Installer tool enables you to install the framework automatically.

If you use a passphrase, you must first add your password to the keychain using /usr/bin/ssh-add -K.

Installation using Composer.

Follow these steps to install the framework:

  1. Create a composer.json file:
{
  "name": "your_company/project_name",
  "description": "Description",
  "repositories": [
    {
      "type": "composer",
      "url": "https://packages.festi.io/"
    }
  ],
  "require": {
    "php": ">=8.1",
    "festi-team/festi-framework-core": "dev-develop",
    "festi-team/festi-framework-database": "dev-develop",
    "festi-team/festi-framework-cli": "dev-develop"
  },
  "minimum-stability": "dev",
  "autoload": {
  "psr-4": {
    "": "src"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "": ["tests"]
    }
  },
  "require-dev": {
    "phpunit/phpunit": "9.*",
    "phpunit/php-code-coverage": "9.*",
    "phan/phan": "4.x",
    "squizlabs/php_codesniffer": "3.*"
  }
}
  1. Run the following command to install dependencies:
composer install
  1. Remove composer.lock
  2. Run the following command to install the essential components for your project:
php ./vendor/bin/festi-install

Installation Types

You can choose from several installation types:

Type Description
dashboard installs the Jimbo system plugin, which is mainly for the administration panel (default).
site installs the Jimbo system plugin and the Contents plugin.
api installs the RESTful system plugin and sets up the framework to work as a RESTful API
rpc installs the Rpc system plugin and sets up the framework to work as a RPC API.
async installs the Festi Async Framework for creating asynchronous services for IoT, Game, Data Streaming, etc.

An async project is a daemon started with php src/service/main.php <host> <port>. It runs on the swoole PHP extension, the only event loop the async framework supports: install the extension, or install the project with --docker y, whose image brings it. The installer says so when the PHP it runs on has no swoole.

During installation, you will be prompted to install demo content. If you agree, basic settings and routing for login/registration will be created. In most projects, this is required, so it is recommended to agree.

Writing your own installation type

InstallerTool resolves a type by looking for ucfirst($installType)."Installer" in the global namespace, so class MyThingInstaller extends Installer plus --install_type mything is a supported way to add one.

Only these hooks are part of the contract. Override what you need and leave the rest alone:

Hook Purpose
getDefaultThemeName() Theme to check out, or null for none.
getSystemPluginScm() Repository of the system plugin.
getExternalPlugins() Extra plugins, as name => repository.
getDatabaseSchemeFiles() Schema dumps to build the migration from.
getExternalInstallDatabaseSchemePath() An extra dump outside the plugin.
getInstallFilesPaths() Directories to copy into the project.
hasDemoContent() / getDemoPath() Whether there is demo content, and where.
migration() How the database is migrated at the end.
onPrepareOptions() / onStart() The options asked for, and the flow itself.

Everything else protected on Installer is internal. The install mechanics live in collaborators — InstallPaths, ProjectDirectory, SubmoduleRepository, ComposerRunner, MigrationScriptWriter and DatabaseCredentials — and are reached through the installer rather than inherited from it.

Methods that used to be on Installer and moved into those collaborators are still available through the DeprecatedInstallerApi trait, so an existing custom installer keeps working. They are deprecated and will be removed in 8.0. Two are already gone rather than deprecated: createConnection() and applyDatabaseScheme(), which had no callers anywhere and were the only reason the installer referenced PDO.

Host Configuration

To properly configure the domain, it should either:

  1. Point directly to [PROJECT_NAME]/src/[dashboard|api|site|service|rpc], or
  2. Be set as a symbolic link to the appropriate directory.

Use the following command to create a symbolic link for the domain:

ln -s /path/to/your_project/src/dashboard /home/[USER_NAME]/my-domain.siter.festi.dev

or in the home directory:

ln -s your_project/src/dashboard my-domain.siter.festi.dev

Example Directory Structure

If your virtual host is configured to point to /home/[USER_NAME]/my-domain.siter.festi.dev, the directory structure should look like this:

/home/[USER_NAME]/
│── your_project/
│   ├── dumps/
│   ├── src/
│   │   ├── dashboard/
│   │   ├── api/
│   │   ├── site/
│   │   ├── service/
│   │   ├── rpc/
│   ├── ...
│── my-domain.siter.festi.dev -> your_project/src/dashboard

Install via console with options

Use the following command to install the framework via the console with options:

php vendor/bin/festi-install  --db_username [USERNAME] --db_password [PASSWORD] --db_name [NAME] --install_path [PATH]

Here are some available options:

Option Description
--docker set to y to install with generate docker files.
--force y to reinstall over an existing project (see Reinstalling with --force).
--install_path path to folder where you do want to install a project.
--install_type this option determines the type of installation to perform.
--db_host the hostname of the database server.
--db_username the username to use when connecting to the database.
--db_password the password to use when connecting to the database.
--db_name the name of the database to use.
--db_type the type of database to use, such as mysql or pgsql.
--db_port the port number to use when connecting to the database.
--timezone the timezone to use for the PHP installation.
--is_install_demo set to y to install basic settings and routing for login/registration .
--site_host the hostname of the site.
--dashboard_base_http the base URL of the dashboard.
--scm_host the hostname of the SCM repository.
--verbose, -v print every command the installer runs and its output (see Verbose Output).
--docker_php_version the version of PHP to use when installing with Docker.
--docker_server the server to use when installing with Docker.
--docker_swoole_version the version of Swoole to use when installing with Docker.
--docker_service_host the service host for async type of project when installing with Docker.
--docker_service_port the service port for async type of project when installing with Docker.

Reinstalling with --force

Without --force the installer refuses a folder that already holds a project (Instance have installed already.). With --force y (or true; n keeps it off) it first removes the previous installation, then installs from scratch:

  • Removed from the folder: only what the installer creates -- .git, vendor, src, plugins, docker, dumps, composer.json, composer.lock, .gitmodules, .gitignore. Anything else you keep there survives.
  • Dropped from the database: every table (and, on PostgreSQL and MSSQL, the views, enum types and constraints) of the configured database, before the migrations run. This cannot be undone; point --db_name at the right database. A Docker install is not affected, since its database container starts empty.
  • Refused: a folder that does not exist, /, and your home directory -- nothing is removed there.

Add -v to see each removed item and the tables dropped.

Docker files

With --docker y the project gets a docker/ folder; start it with docker compose up --build from there. The PHP container runs as www-data, not root. So that the mounted project stays writable on a Linux host, www-data takes your user's UID and GID when the image is built, 1000 by default. If yours differ, export them first:

export UID GID=$(id -g)
docker compose up --build

Apache listens on port 8080 inside the container, and the site is served on http://localhost:8000.

The compose project is named after the project folder, so two generated projects can run on one machine. Give the second one other host ports, in the shell or in docker/.env:

FESTI_HTTP_PORT=8001 FESTI_DB_PORT=3307 docker compose up --build

The database password is not written to docker-compose.yml, which is committed with the project. The installer puts it into docker/.env as FESTI_DB_PASSWORD, and the generated .gitignore ignores that file, so a colleague who clones the project creates it once.

FESTI_DB_PORT is the port the database is reachable on from the host; it defaults to --db_port. The database image has a fixed version (mysql:8.4, postgres:12). session.cookie_secure is off in docker/php/php.ini, because the project is served over plain http there; switch it on when the project is behind HTTPS.

Files the installer adds

Next to what the system plugin brings, the installer writes:

  • .gitignore in the project root, which keeps vendor/, logs/, node_modules/, src/*/local.php (it holds the database password) and .env files out of the repository;
  • for a dashboard or a site, static/css/styles.css, static/css/print.css and static/images/favicon.ico. The theme links them on every page, and they are the place for the project's own styles.

A file the project already has is not replaced.

Override parameters vie environment variables

You can override parameters using environment variables. For example, you can set:

MYSQL_PATH - path to mysql client
export MYSQL_PATH=/usr/local/opt/mysql-client/bin/mysql

DSG Converter

Enables conversion of a database table to DGS (XML/Array (JSON)) and vice versa.

Usage

./vendor/bin/festi-dgs

Just answer the questions and get the result.

If you need to run with a different version of PHP:

/opt/php80/bin/php ./vendor/bin/festi-dgs

You can also launch it by specifying options:

./vendor/bin/festi-dgs --name users --path [PATH_TO_PROJECT] --definition-path [PATH_TO_DEFINITION_DIR] --type dgs --format xml

This will give you a dgs file [PATH_TO_DEFINITION_DIR]/users.xml created from the table with the name users.

./vendor/bin/festi-dgs --name active_users --definition-path [PATH_TO_DEFINITION_DIR] --type table

This will create a table with the name specified in the name option, using the DGS definition file [PATH_TO_DEFINITION_DIR]/active_users.xml to obtain the schema of the table.

Options Summary

Option Description
name table name
definition-path path to create or retrieve DGS definition file
path path to the project root (optional parameter, if not specified, the file will be searched for in the project root).
config path to the project config.php (optional parameter, if not specified, the file will be searched for in the project root).
type what to generate:
  • table - get table in database from XML
  • dgs - get XML/Array from an existing table
format output file format (xml/array) (optional parameter, defaults to xml). Only works when using dgs type.
primary-key column name for default primary key. Default value is id.
schema save the SQL of the run as a migration, or append it to a file with --schema=<file>. See Saving SQL.

You can specify either an absolute path (e.g., /home/user/project) or a path relative to the project root (e.g., path/to/project).

DGS Action Executor

Executes a DGS action (insert, edit, remove, list, or any custom action) from the command line against a live Festi project.

Usage

./vendor/bin/festi-dgs-exec

Just answer the questions and get the result as JSON.

You can also launch it by specifying options:

./vendor/bin/festi-dgs-exec --plugin Pages --dgs pages --action list --path [PATH_TO_PROJECT]

Insert a row:

./vendor/bin/festi-dgs-exec --plugin Pages --dgs pages --action insert --values '{"title":"Home","url":"/"}' --path [PATH_TO_PROJECT]

Use a custom system plugin (e.g. when the DGS preamble calls Core::getSystemPlugin()):

./vendor/bin/festi-dgs-exec --plugin Pages --dgs pages --action list --system-plugin MySystemPlugin --path [PATH_TO_PROJECT]

Options Summary

Option Description
plugin Plugin name that owns the DGS (required)
dgs DGS identifier — XML filename without .xml inside the plugin's tblDefs/ directory (required)
action Action to execute: insert, edit, remove, list, info, or any custom action defined in XML (required)
values JSON object of field values, e.g. '{"name":"Example"}' (optional, defaults to {})
format Output format: json (default) or html
system-plugin Plugin to register as the system plugin (optional, defaults to Jimbo). Required when the DGS has PHP preambles that call Core::getSystemPlugin().
path Path to the project root (optional, defaults to current working directory)
config Path to the project config.php (optional, defaults to [path]/config.php)
schema Save the SQL of the run as a migration, or append it to a file with --schema=<file>. See Saving SQL.

URL Router

Tells you which plugin method a URL of the project is routed to, so you can open the code behind a page without reading the festi_url_rules table. It only reads the rules and the code: no request is sent to the method.

Usage

./vendor/bin/festi-route "/test13/"
TestPlugin::onDisplayTest:125

The answer is the class (or trait) that declares the method, the method and the line it starts on. The URL can be pasted from the browser as it is: the host and the query string are ignored.

./vendor/bin/festi-route "https://app.example.com/report/42/?page=2" --area backend --path [PATH_TO_PROJECT]

Add --verbose to see the area, the pattern of the rule and the file:

./vendor/bin/festi-route /test13/ -v
  • The URL is compared the way the framework compares it, so /test13 does not match a rule written for /test13/. The command says so when the slash at the end is all that is missing.
  • Rules are kept per area. Without --area every area of the project is searched.
  • When several rules match the URL, each is printed with its area and pattern, and the first one the system plugin compares in an area is marked used: that is the rule this installation routes the URL by. Another database can return two database rules in a different order.
  • A project served under a path (http_base, e.g. /app/) usually sets it in its index.php, which the command does not run. Give it with --http-base /app/ and it is cut off the URL the way the framework does.
  • A rule whose plugin or method no longer exists is printed as TestPlugin::onDisplayTest (method not found).
  • The rules are the ones the system plugin routes a request with: the rules in the database, the ones the plugin adds itself (/festi/<store>/ resolves to its onDisplayMain) and $GLOBALS['urlRules']. A rule that a plugin adds while a request is handled, through an event listener, is not listed.
  • With a festi-framework-core that has IUrlRuleFinder, the system plugin itself is asked which rules match, so the answer is the comparison a request is routed by. With an older core the command compares the rules itself, and the default rules of a system plugin built on ApiPlugin (/rpc/<plugin>/<method>/) are not found.
  • An option the command does not have, or a second URL, is an error. --are backend is not read as the URL backend.

Options Summary

Option Description
url The URL, when it is not given as the argument (asked for when missing)
area Look only in this area, e.g. backend (optional, defaults to every area of the project)
system-plugin Plugin registered as the system plugin (optional, defaults to Jimbo)
http-base Path the project is served under (optional, defaults to the project's http_base)
path Path to the project root (optional, defaults to current working directory)
config Path to the project config.php (optional, defaults to [path]/config.php)

Plugin Manager

The Plugin Manager allows creating plugins, their methods (including routing) and their data access code, and setting up a plugin that lives in a repository of its own.

Modes

Mode Description
create Creates a folder for the plugin and everything necessary for it.
url Adds a method to the plugin and entries in the festi_url_rules and festi_url_rules2areas tables.
dgs Creates DGS, adds a method for displaying DGS to the plugin and entries in the festi_url_rules and festi_url_rules2areas tables.
exec Invokes a public method on a plugin instance from the command line, with optional JSON-encoded arguments.
dao Adds search, get, add, change and remove methods for tables to the plugin's DataAccessObject, and optionally a values object.
install Adds a plugin from its own git repository as a submodule and applies the SQL of its install folder.
update Applies the new SQL a newer version of such a plugin brought.

Options Summary

Option Description
mode mode
name method's name or table/XML name
plugin plugin's name
config path to the project config.php (optional parameter, if not specified, the file will be searched for in the project root).
pattern RegExp expression for URL
type method type (default, json, ajax)
area the name of the route work area (pulled from festi_url_areas)
method (exec mode) public method on the target plugin to invoke
args (exec mode) JSON list or object of arguments; list spreads positionally, object binds as named arguments
tables (dao mode) table names separated by commas; the first is the main table
values_object (dao mode) y to also create a values object for the main table
entity (dao mode) name of the main entity, used for the values object class
repository (install mode) git repository of the plugin
dump (install and update modes) dumps directory of the project (optional, defaults to [path]/dumps)
schema save the SQL of the run as a migration, or append it to a file with --schema=<file>. See Saving SQL.

create mode

In this mode, a folder will be created for the plugin and everything necessary for it:

  • plugins/[PLUGIN_NAME]/[PLUGIN_NAME]Plugin.php
  • plugins/[PLUGIN_NAME]/[PLUGIN_NAME]Object.php
  • plugins/[PLUGIN_NAME]/init.php
  • plugins/[PLUGIN_NAME]/templates/
  • plugins/[PLUGIN_NAME]/tblDefs/

Examples

./vendor/bin/festi-plugin
- Enter the mode name - create - Enter the desired plugin name - Expenses

As a result, the following files will be obtained:

  • plugins/Expenses/ExpensesPlugin.php
  • plugins/Expenses/ExpensesObject.php
  • plugins/Expenses/init.php
  • plugins/Expenses/templates/
  • plugins/Expenses/tblDefs/

Settings can also be specified in launch parameters:

./vendor/bin/festi-plugin --mode create --name Expenses

In addition, the corresponding entry will be added to the festi_plugins table.

url mode

In this mode, will be created DGS, a method will be added to the plugin and entries in the festi_url_rules and festi_url_rules2areas tables.

Method types: - default - onDisplay[Name] - ajax - onAjax[Name] - json - onJson[Name]

Examples

./vendor/bin/festi-plugin
- Enter the mode name - url - Enter the plugin name - Incomes - Enter the URL rule - ~^/income/([0-9]+)/$~ - Enter the desired method name - Income - Enter the route work area - backend - Enter the method type - default

As a result, the plugin will receive a method:


    /**
     * @urlRule ~^/income/([0-9]+)/$~
     * @section none
     * @area backend
     * @userType user
     */
    public function onDisplayIncome(Response &$response)
    {
        // code ...

        return true;
    }

Parameters can also be specified in the launch arguments:

./vendor/bin/festi-plugin --mode url --plugin Incomes --pattern "~^/income/([0-9]+)/$~" --name Income --area backend --type default

dgs mode

In this mode, a method for displaying DGS will be added to the plugin and entries in the festi_url_rules and festi_url_rules2areas tables.

If the XML file is not available, it will be automatically generated from the database table using the DSG Converter.

Examples

./vendor/bin/festi-plugin
- Enter the mode name - dgs - Enter the table (DGS) name - users - Enter the plugin name - Users - Enter the desired URL - ~/admin/users/~

As a result, we will get a file: - plugins/Users/tblDefs/user.xml

And in the plugin, we will have a method:


    /**
     * @urlRule ~/admin/users/~
     * @section none
     * @area backend
     * @userType user
     */
    public function onDisplayUsers(Response &$response)
    {
        $store = $this->createStoreInstance("users");

        $store->onRequest($response);

        return true;
    }

exec mode

Invokes a single public method on a plugin instance from the command line. Use this whenever you'd otherwise add a one-off bin script just to call $plugin->doSomething(...) — for example, batch jobs, ad-hoc maintenance, or driving plugin features from CI without writing per-task scaffolding. The mode bootstraps the full Festi runtime, resolves the plugin via Core::getPluginInstance(), runs the method, and prints the return value.

The dispatch guard uses is_callable([$plugin, $method]), which respects calling-scope visibility — only the plugin's genuine public API is reachable. Private, protected, and inherited framework lifecycle methods (e.g. onInitRequest, signinByToken) are rejected with a precise error.

Output marshaling

  • null return → no output (silent success).
  • Scalar return (string, int, bool, float) → printed raw.
  • Array or object return → printed JSON-encoded with JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE.

Argument forms (--args)

--args accepts a JSON value:

  • Omitted or [] → no-argument call: $plugin->method().
  • JSON list (e.g. [2, 5]) → spread positionally: $plugin->method(2, 5).
  • JSON object (e.g. {"format":"webp","quality":85}) → spread as PHP 8.1+ named arguments, bound by parameter name (order-independent): $plugin->method(format: 'webp', quality: 85).

Examples

Run a no-arg method:

./vendor/bin/festi-plugin --mode exec --plugin Contents --method bundleAssets

Pass positional arguments:

./vendor/bin/festi-plugin --mode exec --plugin Reports --method generate --args '[2026,3]'

Pass named arguments — order-independent:

./vendor/bin/festi-plugin --mode exec --plugin Images --method optimize --args '{"quality":85,"format":"webp"}'

Options

Option Description
mode exec
plugin Plugin name (e.g. Contents, Reports). Resolved via Core::getPluginInstance(). Required (prompted if missing).
method Public method on the plugin. Required (prompted if missing).
args JSON list or object of arguments. Optional; defaults to no arguments.
path Project path. Defaults to the current working directory.
config Path to a custom config.php. Defaults to [path]/config.php.

dao mode

Writes the data access code of a plugin for the tables it owns. The tables must already exist in the project database.

./vendor/bin/festi-plugin --mode dao --plugin Shop --tables orders,orders_items --values_object y --entity Order

The first table is the main one. Its methods have the short names; the methods of every other table carry the table name:

Table Methods added to ShopObject
orders (main) search(), get(), add(), change(), remove()
orders_items searchOrdersItems(), getOrdersItems(), addOrdersItems(), changeOrdersItems(), removeOrdersItems()
public function search(array $search = [], array $orderBy = []): array
{
    $sql = "SELECT * FROM orders";

    return $this->select($sql, $search, $orderBy);
} // end search

public function add(array $values): int
{
    return (int) $this->insert('orders', $values);
} // end add

A method the class already has is skipped, so the mode can be run again when the plugin gets another table.

add() returns the identifier the database assigned to the new row, so the table needs an auto-increment key (AUTO_INCREMENT, SERIAL or IDENTITY). On PostgreSQL add() fails for a table without one.

Values object

With --values_object y the mode also creates Domain/Model/<Entity>ValuesObject.php in the plugin folder, in the namespace Plugin\<Plugin>\Domain\Model, and imports it in the DataAccessObject. The class has one FIELD_* constant, one typed getter and one setter per column of the main table; nullable columns get nullable types. An entity id is named the Festi way: id gives getID(), id_user gives FIELD_USER_ID and getUserID().

The class is found by the project's class loader. A project that loads its plugins from a Composer class map needs composer dump-autoload after the file is created. search() and get() of the main table then return values objects instead of arrays (get() returns null when no row matches).

namespace Plugin\Shop\Domain\Model;

class OrderValuesObject extends ValuesObject
{
    public const FIELD_ID = 'id';
    public const FIELD_CUSTOMER_ID = 'id_customer';

    /**
     * @return int
     */
    public function getID(): int
    {
        return $this->get(static::FIELD_ID);
    } // end getID

    /**
     * @param int|null $value
     *
     * @return void
     */
    public function setCustomerID(?int $value): void
    {
        $this->set(static::FIELD_CUSTOMER_ID, $value);
    } // end setCustomerID

    // ...
}

The entity name defaults to the singular of the main table (orders gives Order, orders_items gives OrderItem). An existing values object is never overwritten: it is meant to be edited by hand afterwards, for domain names of getters and for enum constants.

Options

Option Description
mode dao
plugin Plugin name. Required (prompted if missing).
tables Table names separated by commas, the main table first. Required (prompted if missing).
values_object y or n. Prompted if missing; the default answer is n.
entity Name of the main entity. Prompted when a values object is wanted; defaults to the singular of the main table.
path Project path. Defaults to the current working directory.
config Path to a custom config.php. Defaults to [path]/config.php.

install and update modes: a plugin with its own repository

A plugin that lives in a repository of its own, such as Contents, is set up in an existing project with one command:

./vendor/bin/festi-plugin --mode install --path ./src/dashboard/ --dump ./dumps/ \
    --plugin Contents --repository [email protected]:FestiPlugins/PHP_Festi_Plugin_Contents.git

It does three things:

  1. Adds the repository to the plugins directory as a git submodule, the way festi-install adds plugins. The project has to be a git repository.
  2. Copies the plugin's install/install.<database type>.sql into the dumps directory as a new updates<timestamp>.sql. A plugin whose SQL does not add it to festi_plugins gets that row in the same migration, and a plugin without any SQL gets a migration with only that row.
  3. Applies the migrations of the project that the database does not have yet, the new one last, as festi-migrate does.

The SQL goes through a migration of the project on purpose: festi-migrate reads only the dumps directory, so this is how every other database of the project gets the plugin. Commit the submodule and the new migration together.

--plugin is the name of the plugin class without Plugin; the repository has to contain <name>Plugin.php. When anything fails, the plugin folder and the migration are removed again. A plugin that has SQL for other databases but not for the one of the project is refused.

A plugin keeps its later database changes in one growing file, install/update.<database type>.sql. After you take a newer version of the plugin, apply what it brought:

git submodule update --remote src/dashboard/plugins/Contents
./vendor/bin/festi-plugin --mode update --path ./src/dashboard/ --dump ./dumps/ --plugin Contents

When the update file is not the one the project took last, the whole file becomes a new migration and is applied. When it is the same, the command says that there are no new updates and writes nothing. It knows this from a comment at the top of each migration it writes, so do not remove that line:

-- festi-plugin: Contents install/update.mysql.sql sha256=ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb

The whole update file runs again, not only its new part, so it has to be written to be safe on a database that already has the older part. See Idempotency Rules. For a plugin that was set up by festi-install or by hand, the first update takes the whole file and says so.

--schema is refused in these two modes: their SQL is already saved as a migration.

Connecting to the database

By default, the config.php file located in the project root is used to establish a database connection. If you need to use a different file, you need to run the script with the config parameter:

./vendor/bin/festi-plugin --config="cron/config.php"

Database Migration

To migrate the database, follow these steps:

  1. Create a folder for dumps in the root of the project, for example, dump:
/dump/init.sql
/dump/updates1.sql
/dump/updates2.sql
/dump/updatesN.sql
  1. Use the ./vendor/bin/festi-migrate command:
./vendor/bin/festi-migrate

If the config.php file is not located in the root directory of the repository:

./vendor/bin/festi-migrate --path ./src/admin/
./vendor/bin/festi-migrate --path ./src/admin/ --dump ./dump/
./vendor/bin/festi-migrate --path ./src/admin/ --dump ./dump/ --backup ./backups/

If you need to update the crontab, create a file in the following format:

# CRONTAB
*/5 * * * * %PHP% %PROJECT_PATH%/crons/email_worker.php --id 2
*/5 * * * * %PHP% %PROJECT_PATH%/plugins/Queues/queues.php --id 3 --name Balance
*/5 * * * * %PHP% %PROJECT_PATH%/plugins/Queues/queues.php --id 4 --name Tasks

Then, run the migration with parameters for the crontab:

./vendor/bin/festi-migrate --crontab ./dump/crontab.txt --crontab-user user

You can also update the version of the project's static content (festi_settings->js_version) during the migration by using the --up-static-version option.

See Database Migrations for the full guide to authoring migration files.

Parameters in Migrations

A migration can take a value that differs between installations, or that must not be committed, from config.php in the dumps directory. The migration names the value as %%name%%:

INSERT INTO ticketing_systems (caption, redirect_uri, client_id, client_secret)
VALUES ('Discord', 'https://%%host%%/callback/discord/', '%%discord_client_id%%', '%%discord_client_secret%%');
<?php
// dumps/config.php

return [
    'host'                  => 'app.example.com',
    'discord_client_id'     => getenv('DISCORD_CLIENT_ID'),
    'discord_client_secret' => getenv('DISCORD_CLIENT_SECRET'),
];
  • The token has two percent signs on each side, so LIKE '%name%' in a migration is left alone.
  • The value is inserted as it is. The migration writes the quotes around it, and a value with a quote in it has to be escaped in config.php.
  • A %%name%% that config.php does not define stops festi-migrate before the migration is applied, and so does a migration with parameters in a dumps directory without a config.php. Nothing is recorded as applied.
  • A migration without parameters needs no config.php and is applied as before.
  • config.php usually holds secrets: keep it out of git and commit a dumps/config.dist.php that lists the keys.
  • While a migration with parameters is applied, its filled-in SQL exists as updates<number>.rendered.<id>.tmp next to it, so the dumps directory must be writable. The file is removed when the migration ends.
  • --verbose lists the names of the parameters a migration used. When a migration is applied through PHP rather than the mysql or psql client, a verbose run also shows its statements with the values in them.
  • PHP migrations and festi-merge do not fill parameters in: a merged file keeps its %%name%% tokens.

Using PHP Scripts for Database Migration

Sometimes pure SQL is insufficient for writing migration queries, especially when data needs to be corrected in production. For such cases, you can create an updatesN.php file containing a class named UpdatesN inherited from core\util\MigrationUpdates:

use core\util\MigrationUpdates;

class Updates2 extends MigrationUpdates
{
    protected function onStart(): void
    {
        $core = Core::getInstance();

        $search = array(
            'type' => 'catalog'
        );

        $sql = "SELECT * FROM contents";

        $catalogs = $core->db->select($sql, $search);

        $removeIDs = array();
        foreach ($catalogs as $row) {
            if (!empty($row['path']) && !file_exists($row['path'])) {
                $removeIDs[] = $row['id'];
            }
        }

        if ($removeIDs) {
            $search = array(
                'id&IN' => $removeIDs
            );

            $core->db->delete("contents", $removeIDs);
        }
    }
}

Docker

If you use database in docker, you should specify the container name:

./vendor/bin/festi-migrate --path ./src/admin/ --dump ./dump/ --backup ./backups/ --docker db_postgres

Also, you need add dumps folder as volume to container:

  postgres:
    container_name: db_postgres
    image: postgres:16.0-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_DATABASE}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USERNAME}
    volumes:
      - db_data:/var/lib/postgresql/data:delegated
      - ./dumps:/var/www/app/dumps:cached # <-- here

Database Migration Creator

To create a new database migration, use the command:

./vendor/bin/festi-make-migration

The file is created in the project's dumps/ folder and named updates<unix timestamp>.sql, for example updates1789628617.sql. If the dumps/ folder does not exist yet, it is created.

Do not rename the file: festi-migrate looks for migrations matching updates<number>.<sql|php>, and a file with any other name is skipped without a warning.

If the config.php file is not located in the root directory of the repository:

./vendor/bin/festi-make-migration --path ./src/admin/
./vendor/bin/festi-make-migration --path ./src/admin/ --dump ./dumps/

An explicitly given --dump must already exist. Unlike the default path it is not created, because a path that does not exist is almost always a typo, and a migration written where festi-migrate never looks for it is worse than a failed command.

The --php option is also available, to create a PHP migration:

./vendor/bin/festi-make-migration --dump ../../dumps/ --php

For convenience you can add a shortcut to composer.json:

{
    "scripts": {
        "make-migration": "./vendor/bin/festi-make-migration --dump ../../dumps"
    }
}
composer make-migration

From a working file

While a feature is written, its SQL usually collects in one working file, for example with --schema=./dumps/develop.sql (see Saving SQL). festi-migrate does not apply a file with that name. Before the branch is merged, turn it into the next migration:

./vendor/bin/festi-make-migration --develop
./vendor/bin/festi-make-migration --develop=./sql/feature.sql --dump ./dumps/
./vendor/bin/festi-make-migration --develop --not-applied
  • --develop alone takes develop.sql from the dumps folder. --develop=<file> takes another file; write the value with =. A file name after a space is refused, because it would not be read and develop.sql would be renamed.
  • The file is renamed to updates<unix timestamp>.sql in the dumps folder. Its content is not changed, and the working file no longer exists afterwards.
  • The migration is recorded as applied to your database, because its SQL already ran there. festi-migrate then runs it only on the other databases. Use --not-applied for a file you wrote by hand and never ran.
  • When your database has no schema_migration table yet, nothing is recorded and the command says so: the next festi-migrate runs the file there.
  • When the record cannot be written, the file stays renamed and the error gives the INSERT statement to run by hand.
  • A file that does not exist, is empty, is not .sql or is already named updates<number>.sql is refused, and nothing is renamed. --develop cannot be used together with --php.

The migration number is a Unix timestamp rather than a readable date because it is stored in the schema_migration.version column, which is declared as a 32-bit int in all three supported databases. Ten digits fit there; the fourteen of YmdHis do not. This stops working on 19 January 2038, and the fix then is to widen the column to bigint in the three src/migrate/*MigrateAdapter.php adapters.

Merge Migrations Files

The festi-merge tool simplifies the process of combining multiple migration files into fewer, larger files. This is particularly helpful for improving the speed of database migrations during CI/CD deployments and unit testing.

Usage

To merge migration files, use the following command:

./vendor/bin/festi-merge --path ../../dumps

Options

  • --path: (Required) The path to the folder containing migration files.
  • --output: (Optional) The path where the merged files will be stored. If not specified, a merged folder will be created in the input folder.

By merging migration files: - Faster CI/CD Deployments: Consolidated files reduce execution time for database migrations. - Optimized Testing: Accelerates database setup during unit tests. - Improved Development Workflow: Reduces overhead in managing large numbers of migration files.

Utils

CliUtils

Helper class that supports printing messages to the console with a color scheme.

Usage

$tableName = CliUtils::cin("Table Name:");
CliUtils::i("Information");
$type = CliUtils::cin("What do you want to create?", array("a", "b"), "b");

Sometimes you need to mock output/input for unit tests:


CliUtils::$outputCallback = function ($message) {
};

CliUtils::$inputCallback = function (string $label, ?array $enum = null, string $default = '') {
    return 'test';
};

CliUtils::d() prints a debug line that is shown only in verbose mode. AbstractTool switches the mode on when a tool is started with --verbose or -v; a test can do the same with CliUtils::setVerbose(true), and should switch it back off in tearDown() because the mode is process-wide.

CliUtils::d('SQL: '.$sql);            // shown only with --verbose
CliUtils::exception($exp);            // message always, stack trace only with --verbose

FAQ

PHP Version Error

If you encounter PHP version errors such as:

PHP Fatal error:  Composer detected issues in your platform: Your Composer dependencies require a PHP version ">= 8.0.2". You are running 5.4.16. in ...

There are two ways to resolve this:

  1. Update the PHP version on your server to meet the requirements.
  2. Use an environment variable to specify the path to the required PHP version:
export PHP_PATH="/php_path"

Unit Tests

Local

export DB_HOST=127.0.0.1
cd tests/
../vendor/bin/phpunit Tools/DgsConverterTest.php

Docker

cd tests/docker
make start
docker compose run -e DB_HOST=festi-cli-mysql-db php-cli /var/www/vendor/bin/phpunit -c /var/www/tests/phpunit.xml

The stack belongs to this project alone: the compose project is festi-cli and the containers are festi-cli-app, festi-cli-mysql-db, festi-cli-postgres-db and festi-cli-mssql-db, so it can run next to the test stack of another Festi project. The databases are published on a free loopback port that Docker picks; docker compose port mysql-db 3306 prints it if you want to connect from the host.

The tests install a whole Festi project, thousands of files. The compose file sets FESTI_TEST_INSTALL_DIR=/tmp/festi-cli-install so that project lives on the container's own filesystem rather than the macOS bind mount, where writing and deleting it took tens of seconds per test. Without the variable it goes to tests/Resources/install/, as it does in CI.

Access to private Festi packages

The Festi packages on packages.festi.io are published without dist archives, so Composer clones them from gitlab.varteq.com. The test container reaches them over HTTPS with a read-only token. No credential is built into the image.

  1. Create a GitLab personal access token with the read_repository scope only (https://gitlab.varteq.com/-/user_settings/personal_access_tokens).
  2. Store it in the macOS login Keychain, which survives a re-clone of the checkout. -w with no value prompts for the token without echoing it:
    security add-generic-password -U -a "$USER" -s GITLAB_PAT -w
    The Makefile looks it up by both account and service, so the entry has to belong to $USER as written above. Three sources are consulted, in this order:
Order Source
1 the environment — GITLAB_PAT=... make start overrides for one run
2 tests/docker/.env, if you prefer a file
3 the macOS login Keychain

Without any of the three, make start stops with an error naming the chain rather than building an image that cannot clone. 3. Start the stack as usual:

make start
.env is optional: the database values default to the ones in .env.example, so only the token has to be supplied.

The container entrypoint rewrites the [email protected]: remotes to HTTPS using that token, so Composer and the installer can clone private packages. To revoke access, delete the token in GitLab; nothing needs rebuilding. If you would rather use your own SSH key, forward your agent into the container instead and leave GITLAB_PAT unset. 5. Run test:

docker compose run -e DB_HOST=festi-cli-mysql-db php-cli /var/www/vendor/bin/phpunit -c /var/www/tests/phpunit.xml /var/www/tests/Tools/DgsConverterTest.php

React Manager

React Manager creates a React app for a Festi project. The app is a Vite project whose build is written straight into the Festi project as main.js and main.css.

See ReactJS in a Plugin for the full procedure in a project: the directories, the page with the app, and the data for the app.

Modes

Mode Description
create Creates the app and, on request, a page for it in a plugin.
deploy Only for an app made with Create React App by an earlier version: copies its finished build. See below.

Options Summary

Option Description
mode create or deploy (asked for when missing)
name the name of the app (asked for when missing)
path the directory of the React app. Defaults to ./react/<name> for create and to the current directory for deploy
plugin (create mode) the plugin that gets a page showing the app
target (create mode) the folder of the project a build is written to. With plugin it defaults to <plugin>/static/<name>-static
config (create mode) the project's config.php, needed to find the plugin. Defaults to ./config.php

create mode

./vendor/bin/festi-react --mode create --name app --plugin Services --config ./src/dashboard/config.php

Run from the project root, this:

  1. Creates the app in ./react/app/: package.json, vite.config.js, index.html and the starter source.
  2. Adds a method to the plugin Services (you are asked for its name, URL pattern, area and type, as in festi-plugin --mode url) and the template templates/app.phtml, which loads main.css and main.js.
  3. Points the build of the app at plugins/Services/static/app-static/, the folder that page takes the files from.

Then install the dependencies and build:

cd react/app
npm install
npm run build

npm run watch builds again on every change. No file of the app holds a path of your machine: vite.config.js names the build folder relative to itself, so the app can be committed and built by anyone.

Keep the app outside the directory the web server serves. It holds the source and node_modules; only the build has to be reachable from a browser. ./react/<name> in the project root is such a place, and create warns when --path is inside a served directory.

Without --plugin and --target you are asked whether to append the app to a plugin. To create only the app, name the build folder yourself:

./vendor/bin/festi-react --mode create --name app --path ./react/app --target ./src/dashboard/static/app-static

The API of the project

src/rpc/request.js of the app sends JSON-RPC requests. It reads the address of the API and the token of the signed-in user from the data-endpoint and data-access-token attributes of the root element. The method added to the plugin assigns both to the template as empty strings; fill them in there.

deploy mode

This mode is for an app that an earlier version of the CLI created with Create React App. It does not build anything: it copies the files of a finished build (<path>/build/static) to the folder ../<name> next to the app, under names without the content hash (main.2cb2b1b3.js becomes main.js).

npm run build
./vendor/bin/festi-react --mode deploy --name app-static --path [PATH_TO_APP_DIR]

An app created with the create mode of this version does not need it: its build already goes to the right folder under the stable names.

Locale Manager

Locale Manager is a command-line utility that simplifies the process of managing localization files for a project. It allows you to easily initiate a new po file and generate a mo file from it.

Modes

Locale Manager has two modes:

Mode Description
init Use this mode to create a new po file for your project. The utility will scan your project files and create a new po file with all the translatable strings it finds.
generate Use this mode to generate a mo file based on an existing po file. The utility will compile the po file into a mo file that can be used in your project.

Options Summary

Option Description
mode Specifies the mode that Locale Manager should run in (init or generate).
path Specifies the path to the root of the project that Locale Manager should operate on. This is an optional parameter; if not specified, Locale Manager will search for the project in the current working directory.
lang Specifies the language you want to create a mo file for. You can specify the language using its ISO 639-1 language code (e.g., en for English, fr for French).

init mode

This mode creates a new po file for your project. The utility will scan your project files with extention php, phtml, xml and create a new po file with all the translatable strings it finds. Also it gets caption from festi_menues and append to po file.

Examples

To use this mode, run the following command:

./vendor/bin/festi-locale --mode=init --path=/path/where/create/project/

This command will initiate a new po file for your project at the specified path at locale folder.

Or you can run the following command:

./vendor/bin/festi-locale

This will prompt you for the following information:

  • Enter the mode name - init
  • Enter the full project path. If you don't specify a path, the utility will use the current directory - /path/where/create/project/

generate mode

This mode generates a mo file based on an existing po file. The utility will compile the po file into a mo file that can be used in your project.

Examples

To use this mode, run the following command:

./vendor/bin/festi-locale --mode=generate --path=/path/where/create/project/ --lang=en

This command will generate a mo file for the English language in your project at the specified path at locale folder.

Or you can run the following command:

./vendor/bin/festi-locale

This will prompt you for the following information:

  • Enter the mode name - generate
  • Enter the full project path. If you don't specify a path, the utility will use the current directory - /path/where/create/project/
  • Enter the language - en