Upgrade Guide

This file tells a developer or a coding agent what to change in a project when it moves to a new version of Festi Framework. It covers the changes marked (BREAKING) in the Unreleased section of ./CHANGELOG.md, which is the release after 7.2.0. Every change that breaks project code adds a section here, in the same merge request.

All changes described here were made after the git tag 7.3.0. Where this file says "before", it means the code at that tag. Changes made between 7.2.0 and that tag are not described here; the load check in step 2 still reports the ones that stop a class from loading.

How to upgrade a project

  1. Update the dependency.
composer update festi-team/festi-framework-core --with-dependencies
  1. Run the class-load check below. Fix every FAIL and run it again until it prints 0 failed.
  2. Run the static analysis of the project (Phan, PHPStan or what it uses).
  3. Run the tests of the project.
  4. Go through the summary. For each row, run the command in "How to find it" of the section. No match means no work for that row.
  5. Open the pages that use DGS lists, forms, dependent selects and file uploads once. Some errors appear only when a request runs (see the error table below).

Class-load check

PHP compares an overriding method or a redeclared property with its parent when it loads the class. An incompatible class stops with a fatal error at that moment. php -l does not find this: it parses one file and never loads the parent class. Tests find it only for classes they load.

Save this as load-check.php in the project root:

<?php
// Usage: php load-check.php <bootstrap.php> <dir> [<dir> ...]
// Loads every PHP file that declares a class, each in its own process,
// and prints the error of every file PHP refuses to load.

if ($argv[1] === '--load') {
    require $argv[2];
    if (isset($argv[3])) {
        require_once $argv[3];
    }
    exit(0);
}

function declaresClass(string $path): bool
{
    $kinds = [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM];
    $skip = [T_DOUBLE_COLON, T_NEW];
    $previous = null;

    foreach (token_get_all(file_get_contents($path)) as $token) {
        if (!is_array($token) || $token[0] === T_WHITESPACE) {
            continue;
        }
        if (in_array($token[0], $kinds) && !in_array($previous, $skip)) {
            return true;
        }
        $previous = $token[0];
    }

    return false;
}

function load(string $bootstrap, ?string $path = null): array
{
    $command = escapeshellarg(PHP_BINARY).
        ' -d display_errors=1 -d log_errors=0 '.
        escapeshellarg(__FILE__).' --load '.escapeshellarg($bootstrap);
    if ($path !== null) {
        $command .= ' '.escapeshellarg($path);
    }

    exec($command.' 2>&1', $output, $code);

    return [$code, trim(implode("\n  ", array_filter($output)))];
}

$bootstrap = $argv[1];

[$code, $output] = load($bootstrap);
if ($code !== 0) {
    echo "The bootstrap itself fails:\n  ", $output, "\n";
    exit(2);
}

$failed = 0;
$checked = 0;

foreach (array_slice($argv, 2) as $dir) {
    $files = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir)
    );

    foreach ($files as $file) {
        $path = $file->getPathname();
        if ($file->getExtension() !== 'php' || !declaresClass($path)) {
            continue;
        }

        [$code, $output] = load($bootstrap, $path);
        $checked++;

        if ($code !== 0) {
            $failed++;
            echo "FAIL ", $path, "\n  ", $output, "\n";
        }
    }
}

echo $checked, " files loaded, ", $failed, " failed\n";
exit($failed ? 1 : 0);

Run it with the file that sets up class loading for the project, and the directories that hold project classes:

php load-check.php vendor/autoload.php plugins src tests

Notes:

  • The first argument must make the parent classes loadable. If plugin classes of the project are not loaded by Composer, pass a bootstrap file that registers the project autoloader, for example the bootstrap of the test suite. The script stops with The bootstrap itself fails when that file cannot run (for example because it opens a database connection).
  • A file is loaded only when it declares a class, interface, trait or enum. Code at the top level of such a file runs too.
  • Class "X" not found in the output means the bootstrap does not load X. It is not a framework change, unless X is one of the classes in Removed classes, removed methods and wider visibility.
  • PHP reports one fatal error per file. Run the check again after a fix.

Which error belongs to which section

Message Found by Section
Fatal error: Declaration of X::m() must be compatible with Y::m(...) load check Native types and strict_types
Fatal error: Type of X::$p must be T (as in class Y) load check Native types and strict_types
Fatal error: Declaration of X::m(...) must be compatible with Y::m(...), m is onInit, install, getResponseModel, onDisplayMain, getSettings, getUrlRules, addUrlRule, get, set, getID, getRole, getSessionData, getFilterTemplateName, getInputType, callHandlerMethod, getCurrencySymbol, insert or getOption load check The last untyped members have native types
Fatal error: Type of X::$object must be ?IDataAccessObject (as in class ObjectPlugin), Fatal error: Type of X::$requiredFields must be array (as in class DefaultUser) load check The last untyped members have native types
Fatal error: A void function must not return a value, in onInit() or install() php -l, load check The last untyped members have native types
Fatal error: A function with return type must return a value (did you mean "return null;" instead of "return;"?), in one of the methods of the first row php -l, load check The last untyped members have native types
TypeError: X::onDisplayMain(): Return value must be of type ?bool, none returned (also the other methods of the first row, with their type) tests, requests The last untyped members have native types
TypeError: Cannot assign U to property X::$object of type ?IDataAccessObject tests, requests The last untyped members have native types
Fatal error: Access level to X::m() must be public (as in class Y) load check Removed classes, removed methods and wider visibility
Class "AbstractDisplayAction" not found (also AbstractSelectField, ComboField, SetField) load check Removed classes, removed methods and wider visibility
TypeError: Y::m(): Argument #N ($p) must be of type T, U given, called in <project file>, Y is a framework class tests, requests Calls from project code into the framework
TypeError: Store::removeByPrimaryKey(): Argument #1 ($primaryKeyValue) must be of type string\|int, null given tests, requests Store key methods refuse null
TypeError: Y::m(): Return value must be of type T, U returned, Y is a framework class tests, requests Field models built from an array or a class
TypeError: Cannot assign U to reference held by property ListAction::$storageValues of type ?array tests, requests Values received by reference must stay arrays
TypeError: Illegal offset type (PHP 8.1 and 8.2) or TypeError: Cannot access offset of type Festi\Store\Field\ElementName on array (PHP 8.3 and later); with isset() the text ends in in isset or empty tests, requests getElementName() returns an ElementName
Error: Call to undefined method ApiPlugin::setActiveMenu() tests, requests ISystemPlugin has no menu methods
TypeError: Y::m(): Argument #N ($p) must be of type Festi\Core\Utils\T, U given, Y::m is ImageUtils::setImageResize, setImageResizeMagic, createWatermark, Curl::getUrl or ApiUtils::send tests, requests Utility methods take value objects
ArgumentCountError: Too few arguments to function ApiUtils::send(), 1 passed tests, requests Utility methods take value objects
Fatal error: Declaration of X::m(...) must be compatible with Y::m(...), m is updateForeignTableValues, getDataForUpdateForeignTableValues, loadRemoteListValues or getListValuesEndpointUrl load check Proxy methods take value objects
TypeError: Y::m(): Argument #N ($p) must be of type Festi\Store\Proxy\T, U given, m is one of the same four methods tests, requests Proxy methods take value objects
Error: SqlProxy::updateForeignTableValues(): Argument #2 ($values) could not be passed by reference tests, requests Proxy methods take value objects
TypeError: SystemException::__construct(): Argument #3 ($context) must be of type Festi\Core\Exception\ExceptionContext, U given, or PermissionsException::__construct(): Argument #3 ($context), or FieldException::__construct(): Argument #4 ($context) tests, requests Exception constructors take an ExceptionContext
Error: Unknown named parameter $data (also $source, and $displayMessage for FieldException), in a line that creates an exception tests, requests Exception constructors take an ExceptionContext
SystemException: X::$fields is not a property of the store model: the schema part is in $this->parts, ... (also $actions, $filters and the other ten names), X is a model class of the project tests, requests StoreModel keeps the schema parts in a StoreSchemaParts
Error: Typed property StoreModel::$parts must not be accessed before initialization tests, requests StoreModel keeps the schema parts in a StoreSchemaParts
SystemException: Cannot access property X::$primaryKey (also $store, $_name, $attributes, $options), X is a store model class tests, requests StoreModel keeps the schema parts in a StoreSchemaParts
SystemException: X::$orderByFieldName is not a property of the store: the value is in $this->pagination, ... (also $orderByDirection, $rowsPerPage, $currentPage, $totalRows), X is a store class tests, requests Store keeps its pagination and its components in two objects
SystemException: X::$proxy is not a property of the store: the value is in $this->components, ... (also $model, $view, $rulesManager), X is a store class tests, requests Store keeps its pagination and its components in two objects
Error: Cannot access property X::$ident (also $_name, $_options and every other property a store declares), X is a store class tests, requests Store keeps its pagination and its components in two objects
Error: Typed property Store::$pagination must not be accessed before initialization (also $components) tests, requests Store keeps its pagination and its components in two objects
Fatal error: Declaration of & X::getStore(): core\store\IStore must be compatible with & AbstractAction::getStore(): Store load check AbstractAction::getStore() returns Store
SystemException: X::$plugin is not a property of the store: the value is in $this->components, read it and set it as $this->components->plugin, X is a store class tests, requests Store keeps its plugin in its components
SystemException: X::getRulesManager() is not a method of the store: the rules manager is in $this->components, ..., or Error: Call to undefined method X::getRulesManager(), X is a store class tests, requests Store keeps its plugin in its components
Error: Attempt to assign property "plugin" on null, in a store constructor that sets the plugin before parent::__construct(); Error: Attempt to modify property "plugin" on null, in getPlugin() or setPlugin() of a store created without its constructor tests, requests Store keeps its plugin in its components
SystemException: X::listeners() is not a section method of the schema: declare the section in bindings(ISchemaBindings $bindings), with $bindings->listeners() (also relations(), routers(), rules(), externalValues()), X is a schema class tests, requests ClassSchema declares five sections in bindings()
Error: Call to undefined method Festi\Store\Schema\ClassSchema::listeners() (also relations(), routers(), rules(), externalValues()) tests, requests ClassSchema declares five sections in bindings()

Summary

Effort: S is a few lines, M is several files, L is every class that extends a framework class.

Change Who is affected Effort Section
Framework classes declare native types Classes that override a framework method or redeclare a framework property L Native types and strict_types
Framework parameters are typed Code that passes null, an array or an object where a scalar is declared, and files with strict_types=1 S Calls from project code into the framework
Field attributes must be strings Stores whose model is built from an array or a class S Field models built from an array or a class
removeByPrimaryKey(), removeChildByPrimaryKey(), updateByPrimaryKey() take int\|string Code that can pass null as the key S Store key methods refuse null
List rows and foreign key values are typed ?array Listeners that replace these values through the event target S Values received by reference must stay arrays
The framework does not log DGS action failures Projects that want these failures in their log S DGS action failures are not logged by the framework
IStoreField::getElementName() returns an object Code that uses the result as an array key, in ===, in json_encode() or in http_build_query() S getElementName() returns an ElementName
ISystemPlugin has no setActiveMenu() and getActiveMenu() Code that calls them on the system plugin S ISystemPlugin has no menu methods
ForeignKeyLoadAction answers dependent field loading with JSON Projects with ajaxChild fields and their own theme or JavaScript M ForeignKeyLoadAction answers with JSON
Classes and methods removed, four methods made public Classes that extend or call them S Removed classes, removed methods and wider visibility
ImageUtils::setImageResize(), setImageResizeMagic(), createWatermark(), Curl::getUrl() and ApiUtils::send() take value objects Code that calls them, except getUrl() with one or two arguments S Utility methods take value objects
Four protected proxy methods take value objects: updateForeignTableValues(), getDataForUpdateForeignTableValues(), loadRemoteListValues() and getListValuesEndpointUrl() Proxy classes of the project that override or call one of them S Proxy methods take value objects
SystemException and FieldException take an ExceptionContext in place of $data and $source; FieldException has no $displayMessage Code that creates such an exception with a third positional argument (a fourth for FieldException) or with the named argument data or source, and project exceptions that pass these to parent::__construct() S Exception constructors take an ExceptionContext
StoreModel keeps $fields, $actions, $filters and ten more schema parts in $this->parts, a StoreSchemaParts; $store, $primaryKey and $_name are no longer items of the model Project classes that extend a store model and use or declare one of these properties, and code that reads a property or an item of a model from outside S StoreModel keeps the schema parts in a StoreSchemaParts
Store keeps $rowsPerPage, $currentPage, $totalRows, $orderByFieldName and $orderByDirection in $this->pagination, a StorePagination, and $model, $view, $proxy and $rulesManager in $this->components, a StoreComponents Project classes that extend Store or PluginStore and use or declare one of these nine properties S Store keeps its pagination and its components in two objects
AbstractAction::getStore() returns Store instead of IStore Action classes of the project that override getStore() with the return type IStore S AbstractAction::getStore() returns Store
Store keeps $plugin in $this->components, and has no getRulesManager(): the rules manager is $this->components->rulesManager Project classes that extend Store or PluginStore and use or declare $plugin, or call or declare getRulesManager() S Store keeps its plugin in its components
ClassSchema has no relations(), listeners(), routers(), rules() and externalValues(): a schema declares these five sections in bindings(ISchemaBindings $bindings) Project classes that extend ClassSchema or a schema class of the project and declare one of the five methods S ClassSchema declares five sections in bindings()
onInit() of a plugin and seventeen more methods declare a return type, ObjectPlugin::$object and DefaultUser::$requiredFields declare a type, PermissionsException has a constructor Every plugin class that declares onInit(); system plugins, system objects, user classes, field classes, proxy classes and plugins that override one of the methods or redeclare one of the two properties L The last untyped members have native types

Native types and strict_types

CHANGELOG entries: src/util, src/exception, src/response, src/event and src/locale; Store, its traits, PluginStore, the models, rules, views, events and schema builders; the fields; the proxies; the actions; Core, Entity, Response, DefaultUser, Logger, the Core traits, the plugin base classes and the system object; FestiTestCase and TestUtils.

What changed

The framework files declare strict_types=1 and native parameter, return and property types. PHP checks a project class against them when it loads the class:

  • An override that leaves out a return type the parent declares is a fatal error.
  • An override that declares a parameter or return type the parent does not accept is a fatal error. A parameter type may be wider than the parent type, a return type may be narrower.
  • An override must return by reference (function &name()) when the parent does.
  • A redeclared property must have exactly the type of the parent property. A redeclaration without a type is a fatal error.
  • An override that declares no parameter type stays valid. Only the return type has to be added to such a method.

EditAction::load() and EditAction::loadRow() return static.

Who is affected

Every project class that extends or implements a framework class and overrides a method or redeclares a property: stores, models, actions, fields, proxies, plugins, Response subclasses and test classes.

A class that only calls framework methods is not affected by this part. See the two caller sections below.

How to find it

Run the class-load check. It reports each incompatible class with the parent signature in the message.

To list the candidate classes:

grep -rnE --include='*.php' --exclude-dir=vendor \
  'extends (Store|PluginStore|StoreModel|Response|DefaultUser|[A-Za-z]*(Action|Field|Proxy|Plugin|TestCase))\b' .

What to do

Copy the parameter and return types of the parent method into the override. Copy the type of the parent property into the redeclaration, or delete the redeclaration and set the value in the constructor or onInit(). The signatures that projects override most are listed under Signatures by area. For any other method, the fatal error message prints the parent signature.

Before:

class OrdersStore extends Store
{
    protected $parentFieldName = 'id_customer';

    public function getPrimaryKey()
    {
        return 'id_order';
    }

    public function updateByPrimaryKey(int $primaryKeyValue, $values)
    {
        $values['mdate'] = date('Y-m-d H:i:s');

        return parent::updateByPrimaryKey($primaryKeyValue, $values);
    }
}

class OrderDateField extends DatetimeField
{
    public function getFormat()
    {
        return 'd.m.Y';
    }
}

PHP reports the first of these for each class:

Type of OrdersStore::$parentFieldName must be ?string (as in class Store)
Declaration of OrdersStore::getPrimaryKey() must be compatible with Store::getPrimaryKey(): string
Declaration of OrdersStore::updateByPrimaryKey(int $primaryKeyValue, $values) must be compatible with Store::updateByPrimaryKey(string|int $primaryKeyValue, array $values): bool
Declaration of OrderDateField::getFormat() must be compatible with DatetimeField::getFormat(): string

After:

class OrdersStore extends Store
{
    protected ?string $parentFieldName = 'id_customer';

    public function getPrimaryKey(): string
    {
        return 'id_order';
    }

    public function updateByPrimaryKey(
        int|string $primaryKeyValue,
        array $values
    ): bool
    {
        $values['mdate'] = date('Y-m-d H:i:s');

        return parent::updateByPrimaryKey($primaryKeyValue, $values);
    }
}

class OrderDateField extends DatetimeField
{
    public function getFormat(): string
    {
        return 'd.m.Y';
    }
}

The members that got their types last, onInit() of a plugin among them, are in The last untyped members have native types, with a script that adds the return types.

Signatures by area

The lists hold the current signatures, copied from src/ and tests/ and written on one line. [Trait] after a line names the trait that declares the method.

Stores

Store (src/store/Store.php and the Store*Trait files):

public function __construct(IDataAccessObject &$connection, string $ident, array &$options = [], ?Core $core = null)
protected function onInit(): void
protected function onPrepareOptions(array &$options): void
public function getDefaultErrorMessage(): string
public function getCurrentUrl(): string
public function getFieldFiltersConditions(): array
public function cloneInstance(): Store
public function onRequest(Response &$response): bool
public function createActionInstance(string $action, bool $isSaveActon = true): AbstractAction
public function getActionNameFromRequest(): string
public function getStoreName(): string
public function getIdent(): string
public function getName(): string
public function getPrimaryKey(): string
public function &getModel(): StoreModel
public function &getView(): StoreView
public function &getProxy(): IProxy
public function &getConnection(): IDataAccessObject
public function &getPlugin(): ?AbstractPlugin
public function load(bool $isAllColumns = false): array
public function search(array $search = []): array
public function loadRow(array $search = [], bool $isAllColumns = false): ?array
public function loadRowByPrimaryKey(mixed $primaryKeyValue, bool $isCheckPermission = true, bool $isAllColumns = false): ?array
public function hasPermissionToLoadRowByPrimaryKey(mixed $primaryKeyValue): bool
public function addAllowedTableRow(array|int|string $primaryValues): void
public function removeByPrimaryKey(int|string $primaryKeyValue): bool
public function removeChildByPrimaryKey(int|string $primaryKeyValue): bool
public function updateByPrimaryKey(int|string $primaryKeyValue, array $values): bool
public function aggregate(?array &$sourceData): ?array
public function loadForeignKeys(): bool
public function loadForeignKeyValues(IStoreForeignField &$field): bool
public function getRequestParam(string $key, ?array &$request = null): mixed
public function setRequestParam(string $key, mixed $value): void
public function getPostParam(int|string $key): mixed
public function getSessionValue(string $key, ?string $ident = null): mixed
public function setSessionValue(string $key, mixed $value, ?string $ident = null): void
public function getParentValue(): mixed
public function getParentValues(string|false|null $storeIdent = false): mixed
public function getParentFieldName(): string|false
public function getOrderByFieldName(): ?string
public function getOrderByDirection(): string
public function getOption(string $name): mixed   [OptionsTrait]
public function &getOptionByRef(string $name): mixed   [OptionsTrait]
public function setOption(string $key, mixed $value): void   [OptionsTrait]

core\store\PluginStore:

public function __construct(string $ident, ?IPlugin $plugin = null, array $options = [])

core\store\IStore, for a class that implements it without extending Store:

public function getOption(string $name): mixed

Typed properties of Store:

protected string $ident
protected mixed $session
protected IDataAccessObject $connection
protected Festi\Store\StoreComponents $components
protected Festi\Store\StorePagination $pagination
protected ?string $parentFieldName
protected array $request
protected Core $core

The model, the view, the proxy, the rules manager, the page and the order of a store are not properties of Store: see Store keeps its pagination and its components in two objects. Its plugin is not one either: see Store keeps its plugin in its components.

Listener interfaces a store or a plugin implements (src/store/event/, src/store/proxy/):

IBeforeInsertListener::onBeforeInsertHandler(StoreActionEvent &$event): void
IBeforeUpdateListener::onBeforeUpdateHandler(\StoreActionEvent &$event): void
IInsertListener::onInsertHandler(StoreActionEvent &$event): void
IUpdateListener::onUpdateHandler(StoreActionEvent &$event): void
IUpdatedValuesListener::onUpdatedValuesHandler(\StoreActionEvent &$event): void
IBeforeRemoveListener::onBeforeRemoveHandler(StoreActionEvent &$event): void
IRemoveListener::onRemoveHandler(StoreActionEvent &$event): void
IActionItemsListener::onActionItemsHandler(StoreActionEvent &$event): void
IListActionsListener::onListActionsHandler(StoreActionEvent &$event): void
IListGropedActionsListener::onListGropedActionsHandler(StoreActionEvent &$event): void
IListRowStyleListener::onListRowStyleHandler(ListRowStyleEvent &$event): void
ILoadChildFieldValuesListener::onLoadChildFieldValuesHandler(StoreActionEvent $event): void
IPrefillFieldsValues::onPrefillFieldsValues(\StoreActionEvent &$event): void
IPrepareActionFormListener::onPrepareActionFormHandler(FestiEvent &$event): void
IPrepareListItem::onPrepareListItemHandler(IStoreField &$field, mixed &$value, array &$row): void
IRowPermissionListener::hasPermissionToAccessRowHandler(mixed $primaryKeyValue): bool
IFieldEncryptValueListener::onFieldEncryptValueHandler(StoreFieldCryptoEvent &$event): void
IFieldDecryptValueListener::onFieldDecryptValueHandler(StoreFieldCryptoEvent &$event): void
IFrontendEventListener::onStoreFrontendEventHandler(FrontendStoreActionEvent &$event): void
IStoreProxyForeignKeyValuesListener::onPrepareStoreForeignKeyFieldValues(Store &$store, IStoreForeignField &$field, array &$info): void

Models, rules and views:

StoreModel::__construct(Store &$store)
StoreModel::load(): void
StoreModel::get(string $name): mixed
StoreModel::set(string $key, mixed $value): void
StoreModel::getPrimaryKey(): string
StoreModel::getAttributesOptions(): array                    (protected)
StoreModel::getActionsAttributesOptions(): array             (protected)
StoreModel::getSectionsAttributesOptions(): array            (protected)
StoreModel::createFieldInstance(FieldModel $model, ?int $index = null): IStoreField
StoreModel: protected Festi\Store\Model\StoreSchemaParts $parts
Festi\Store\Model\StoreSchemaParts implements ArrayAccess
Festi\Store\Model\StoreSchemaParts::offsetGet(mixed $offset): array    (returns a reference)
Festi\Store\Model\StoreSchemaParts::offsetSet(mixed $offset, mixed $value): void
FieldModel::getAttributes(): array                           (abstract)
FieldModel::hasOptions(): bool                               (abstract)
FieldModel::getOptions(): array                              (abstract)
Festi\Store\Rule\AbstractRule::__construct(protected array $attributes = [])
Festi\Store\Rule\AbstractRule::onInit(IStore &$store): void
Festi\Store\Rule\AbstractRule::getAttributesFields(): ?array (abstract protected)
IActionView::onResponse(AbstractAction &$action, Response &$response, ?array &$vars): bool
core\store\view\IActionViewProvider::createViewInstance(StoreView $storeView): IActionView
StoreView::onActionResponse(AbstractAction &$action, Response &$response, ?array &$vars = null): bool

Actions

AbstractAction:

public function __construct(Store &$store, array $options = [])
protected function onInit(): void
public function isGeneral(): bool
public function onStart(Response &$response): bool
protected function getRequestFields(): ?array
protected function getDataFromRequest(): array
public function getUrl(array $params = []): string
protected function error(Response &$response): bool
public function doApplyError(Response &$response): bool
public function doHandleException(Throwable $exp, bool $hasParentTransaction = false): void
public function event(string $eventName, array &$data = []): StoreActionEvent
protected function fireEvent(IEvent &$event): void
public function &getStore(): Store
public function isExec(): bool
abstract public function getActionName(): string
public function getAttribute(string $key): mixed
public function onDisplayForm(Response &$response): void
public function getValues(): ?array
protected function hasUserPermission(string $section): bool
public function setError(?string $error, ?Exception $exp = null): void   [ErrorStateTrait]
public function getLastError(): ?string   [ErrorStateTrait]

InsertAction (parent of EditAction):

public function onUpdate(Response &$response): ?array
protected function getRequest(): ?array
public function apply(array $request, ?Response &$response = null): ?array
protected function getPreparedValues(array $request): array
public function getPrimaryKeyValue(): mixed
public function setValues(?array $data): void
public function isCustomUpdate(): bool
public function isDisplaySubmitButton(): bool
public function getTemplateName(): string   [DisplayActionTrait]
public function isDisplayFieldIntoForm(IStoreField $field): bool   [InsertActionFieldRenderTrait]
public function fetchFieldInput(IStoreField &$field): ?string   [InsertActionFieldRenderTrait]
public function fetchFieldPreview(IStoreField &$field): ?string   [InsertActionFieldRenderTrait]

EditAction and RemoveAction:

EditAction::load(mixed $primaryKeyValue, bool $isCheckPermission = false): static
EditAction::loadRow(array $search): static
EditAction::fetchFieldInput(IStoreField &$field): ?string
RemoveAction::onRemove(Response &$response): ?array          (protected)

ListAction:

public function getTemplateName(): string
protected function getListControlsPresenter(): IListControlsPresenter
protected function getListRowPresenter(): IListRowPresenter
protected function onStorageRow(array $row, array $fields): array   [ListActionRowsTrait]
protected function onStorageRowValue(IStoreField $field, array $row): array   [ListActionRowsTrait]
public function getAllowedActions(array $rowValues = []): array   [ListActionRowsTrait]
public function fetchDisplayValue(IStoreField $field, mixed $value, ?array $row = null): mixed   [ListActionRowsTrait]
public function getActionAttribute(string $actionName, array $rowData = []): ActionOptions   [DisplayActionTrait]
protected function getValueWithLink(mixed $value, IStoreField $field, mixed $primaryKeyValue, array|bool|null $row = false): mixed   [DisplayActionTrait]
protected function getCellValue(IStoreField $field, ?string $value, ?array $row = null): mixed   [DisplayActionTrait]

Typed properties:

AbstractAction: protected Store $store
AbstractAction: protected StoreModel $model
AbstractAction: protected bool $hasError
AbstractAction: protected ?string $lastErrorMessage
AbstractAction: protected ?Exception $exception
InsertAction:   protected ?array $updateInfo
InsertAction:   protected ?array $data
InsertAction:   public mixed $primaryKeyValue
ListAction:     protected ?array $storageValues
ListAction:     protected mixed $storageTotals
ListAction:     public mixed $primaryKeyValue

Fields

AbstractField and the IStoreField* interfaces:

public function __construct(Store &$store)
public function onInit(FieldModel $scheme): void
protected function getAttributesFields(): array
protected function getTemplatePath(): string
protected function getBaseTemplatePath(): string
public function has(string $name): bool
public function get(string $name): mixed
public function set(string $name, mixed $value): void
public function getAttributes(): array
public function getQueryWhere(): ?string
public function getName(): string   [AbstractFieldIdentityTrait]
public function getType(): string   [AbstractFieldIdentityTrait]
public function getCaption(): string   [AbstractFieldIdentityTrait]
public function getElementName(): ElementName   [AbstractFieldIdentityTrait]
public function getEditInput(mixed $value = null, ?array $row = null): ?string   [AbstractFieldRenderTrait]
public function getDisplayValue(mixed $value, ?array $row = null): ?string   [AbstractFieldRenderTrait]
public function getPreviewValue(mixed $value, ?array $row = null): ?string   [AbstractFieldRenderTrait]
protected function getDefaultCellValue(mixed $value, ?array $row = null): string   [AbstractFieldRenderTrait]
public function getValueFromRequest(array $request = []): mixed   [AbstractFieldValueTrait]
public function isValidValue(mixed $value): bool   [AbstractFieldValueTrait]
public function setValue(mixed $value): void   [AbstractFieldValueTrait]
public function getValue(): mixed   [AbstractFieldValueTrait]
protected function onPrepareValue(mixed &$value): void   [AbstractFieldValueTrait]
public function getKeyInRequest(): string   [AbstractFieldValueTrait]
public function getInputType(): string   [AbstractFieldValueTrait]
public function getFormattedValue(mixed $value): ?string   [AbstractFieldFormatTrait]
public function getFormatedValue(mixed $value, string $format): string   [AbstractFieldFormatTrait]
public function getFormattedFilterValue(mixed $value): ?string   [AbstractFieldFilterTrait]
public function getFilterType(): ?string   [AbstractFieldFilterTrait]
public function getFilterKey(): string   [AbstractFieldFilterTrait]
protected function getFilterTemplateName(): string   [AbstractFieldFilterTrait]
protected function fetchFilterTemplate(): string   [AbstractFieldFilterTrait]
protected function onFilterFetch(): void   [AbstractFieldFilterTrait]
public function fetchFilter(): ?string   [AbstractFieldFilterTrait]
public function getFilterValues(): ?array   [AbstractFieldFilterTrait]
public function getCssClassName(bool|string $postfix = false): string   [AbstractFieldCssTrait]
public function getElementAttributes(): string   [AbstractFieldCssTrait]
public function getEncryptValue(?string $value): string   [AbstractFieldCryptoTrait]
public function getDecryptedValue(string $value): ?string   [AbstractFieldCryptoTrait]
public function getViewValue(mixed $value): ?string   [AbstractFieldCryptoTrait]
public function isVirtualField(?string $actionName = null): bool   [AbstractFieldStateTrait]
public function isCustom(?string $section = null): bool   [AbstractFieldStateTrait]
public function isRequired(): bool   [AbstractFieldStateTrait]
public function hasUserPermission(): bool   [AbstractFieldStateTrait]

Other field classes:

DatetimeField::getFormat(): string
DatetimeField::getPreparedValueForEdit(mixed $value, string $format): string   (protected)
TimestampField::getPreparedValueForEdit(mixed $value, string $format): string  (protected)
SelectField::getValuesList(): array
SelectField::onInitOptions(FieldModel $scheme): void                           (protected)
SelectField: public array $valuesList
ForeignKeyField::setValuesList(array $values): void
ForeignKeyField::getValuesList(): array
ForeignKeyField::getValuesWhere(): ?string
FileField::getUploadFilePath(array $rowData = []): string
FileField::getAllowedMimeTypes(): array                                        (protected)
FileField::onUploadFile(StoreActionEvent $event): void
HandlerField::callHandlerMethod(array $params, string $methodPostfix): mixed
PriceField::getCurrencySymbol(): string|false                                  (protected)

Typed properties of AbstractField:

protected Store $store
protected core\store\action\IStoreAction $action
protected array $attributes
protected ?int $index
protected mixed $itemValue
protected ?array $filterValues
protected mixed $filterValue
public ?string $lastErrorMessage

Proxies

IProxy and IProxyRepository, implemented by SqlProxy and its subclasses and by core\store\proxy\OpenApiProxy:

public function loadListValues(bool $isAllColumns = false): array
public function getCount(): int
public function loadForeignAssigns(int|string|null $primaryValue, array $options): array
public function loadForeignValues(array $options): array
public function loadForeignKeyValues(IStoreForeignField &$fields): bool
public function updateManyToManyValues(Many2manyField $field, int $id, array $values): bool
public function removeAllManyToManyValuesByPrimaryKey(int|string $primaryKeyValue): bool
public function getQueryColumns(bool $isAllColumns = false): array
public function getQueryJoins(array $columns): array
public function getQueryWhere(): array
public function setUseLimit(bool $isUseLimit): void
public function isUseLimit(): bool
public function loadAggregations(): array
public function createAuditTable(string $auditTableName, string $originalTableName): bool
public function getLikeCondition(string $column, string $value, bool $caseSensitive = false, bool $isNotClause = false): array
public function getFieldTableName(string $table, string $column): string
public function isBegin(): bool
public function begin(): bool
public function commit(): void
public function rollback(): void
public function insert(array $values): mixed
public function updateByPrimaryKey(string|int|bool $primaryKeyValue, array $values): bool
public function search(array $search): array
public function loadRow(array $search, bool $isAllColumns = false): ?array
public function removeByPrimaryKey(string $primaryKeyValue): int
public function loadRowByPrimaryKey(mixed $id, bool $isAllColumns = false): ?array

StoreProxy and SqlProxy:

StoreProxy::__construct(Store &$store)
StoreProxy::&getConnection(): IDataAccessObject
StoreProxy::getConcatCondition(array $condition): string                       (abstract protected)
StoreProxy::getFilterConditionsByField(IStoreField $field): ?array             (protected)
StoreProxy::getColumnNameByFilter(IStoreField $field): ?string                 (protected)
SqlProxy::getWhereClause(array $conditions): string                            (protected)
SqlProxy::getQueryGroupBy(): ?string                                           (protected)
SqlProxy::getQueryLimit(): string                                              (protected)
SqlProxy::getQueryHaving(): array                                              (protected)
SqlProxy::getSqlColumnName(IStoreField $field): string                         (protected)
SqlProxy::getSearchSql(array $search, bool $isAllColumns = false): string      (protected)
SqlProxy::getMany2manyQuery(Many2manyField $field): string                     (abstract protected)

Typed properties of StoreProxy: protected Store $store, protected StoreModel $model, protected IDataAccessObject $connection.

Plugins, Response, Core and the system object

AbstractPlugin and ObjectPlugin:

AbstractPlugin::onInit(): void
AbstractPlugin::getSetting(string $key): mixed
AbstractPlugin::hasSetting(string $key): bool
AbstractPlugin::createStoreInstance(string $storeName, array $params = []): Store
AbstractPlugin::createStoreInstanceByConnection(IDataAccessObject $connection, string $storeName, array $params = []): Store
AbstractPlugin::getName(): string
AbstractPlugin::__isInstalled(): bool
AbstractPlugin::__getSettings(): array|false
AbstractPlugin::hasUserPermissionToSection(string $sectionIdent, ?DefaultUser $user = null): bool
AbstractPlugin::getPluginTemplatePath(): ?string
AbstractPlugin::getPath(): string
AbstractPlugin: protected Core $core
AbstractPlugin: protected core\util\PluginWrapper $plugin
ObjectPlugin: protected ?IDataAccessObject $object
ObjectPlugin::onInit(): void
ObjectPlugin::getObject(?string $name = null, ?string $pluginName = null): ?IDataAccessObject
ObjectPlugin::getSystemObject(string $name = 'System'): object

Response:

public function __construct(string $type = self::NORMAL, ?string $actionType = null)
public function setType(string $type): void
public function getType(): string
public function setAction(string $type): void
public function getAction(): ?string
public function setPlugin(AbstractPlugin &$plugin): void
public function addParam(array|string $name, mixed $value = false): void
public function flush(bool $status = true): void
public function isFlush(): bool
public function getContent(): mixed
public function setStatus(int $status): void
public function setOverride(bool $isOverride): void
public function setAfter(mixed $type, mixed $command): void   [ResponseAfterActionTrait]
public function setAfterCallback(string $pluginName, string $method): void   [ResponseAfterActionTrait]
public function addMessage(string|array $message): void   [ResponseMessageTrait]
public function addNotification(mixed $message): void   [ResponseMessageTrait]
public function send(?IPlugin $plugin = null): bool   [ResponseSenderTrait]

Typed properties of Response: protected int $_status, protected string $type, protected ?string $action, protected ?AbstractPlugin $plugin, protected bool $_isOverride.

Entity, Core, ISystemPlugin and SystemObject:

Entity::getPreparedData(array $request, array $needles, ?array &$errors = []): array
Entity::getExtendData(array $request, array $needles, ?array &$errors = []): array
Entity::fillString(?string $string, array|bool|null $values = false): string   (static)
Core::call(string $plugin, string $method, array $params = [], array $options = []): mixed
Core::&getPluginInstance(string $plugin, array $options = []): mixed
Core::&getSystemPlugin(): ISystemPlugin
Core::fireEvent(mixed $eventType, mixed &$data = null): ?array
ISystemPlugin::bindRequest(array $options = []): mixed
ISystemPlugin::onInitRequest(array $options = []): void
ISystemPlugin::getResponseModel(array $call, array $regs = []): Response
ISystemPlugin::install(): void
ISystemPlugin::onDisplayMain(Response &$response, string|false $storeName = false, string|false $pluginName = false, array $params = []): ?bool
ISystemPlugin::getSetting(string $key): mixed
ISystemPlugin::hasSetting(string $key): bool
ISystemPlugin::hasUserPermissionToSection(string $sectionIdent, ?DefaultUser $user = null): bool
ISystemPlugin::getName(): string
ISystemPlugin::onException(array $event): void
ISystemPlugin::setPermissionSection(string $name, int $mask, ?int $userType = null, ?array $users = null): int
ISystemPlugin::refreshPermissionSections(): void
ISystemPlugin::getUserTypesBySection(string $section): array
ISystemPlugin::getUsersBySection(string $section, array $search = []): array
SystemObject::getPrefix(): string
SystemObject::getSettings(): array
SystemObject::getUrlRules(array $search): array   [SystemObjectUrlTrait]
SystemObject::addUrlRule(array $values): mixed   [SystemObjectUrlTrait]
SystemObject::isInstalled(): bool
SystemObject::getPlugin(array|string $search): array
SystemObject::getPlugins(array $search): array
SystemObject::getTable(string $name): string                                   (protected)
DefaultUser::__construct(array &$_sessionData)
DefaultUser::doLogin(array $fields): bool
DefaultUser::logout(): void
DefaultUser::isLogin(): bool
DefaultUser::getValue(string $name): mixed
DefaultUser::get(string $name): mixed
DefaultUser::set(string $name, mixed $value): mixed
DefaultUser::getID(): mixed
DefaultUser::getRole(): mixed
DefaultUser::getSessionData(): array
DefaultUser: protected array $requiredFields

Events, exceptions and utilities

EventDispatcher::addEventListener(string $type, mixed $listener): bool
EventDispatcher::dispatchEvent(IEvent &$event): ?array
EventDispatcher::removeEventListener(string $type, callable|array|string $listener): void
EventDispatcher::hasEventListener(string $type, callable|array|string $listener): bool
FestiEvent::__construct(mixed $type, mixed &$currentTarget = null)
FestiEvent::getType(): string
FestiEvent::&getTarget(): mixed
FestiEvent::&getTargetValueByKey(mixed $key): mixed
FestiEvent::setTargetValueByKey(mixed $key, mixed $value): void
SystemException::__construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string|false|null $displayMessage = false, ?Throwable $previous = null)
PermissionsException::__construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string|false|null $displayMessage = false, ?Throwable $previous = null)
SystemException::setDisplayMessage(string|false|null $message): void
SystemException::getDisplayMessage(): string
Permissions::doValidate(array $options = []): void                             (static)
OptionsTrait::getOption(string $name): mixed
OptionsTrait::&getOptionByRef(string $name): mixed
OptionsTrait::setOption(string $key, mixed $value): void
OptionsTrait::getOptions(): array

OptionsTrait is used by Core, AbstractPlugin, Store and AbstractAction.

Test helpers

FestiTestCase (tests/FestiTestCase.php):

protected function setUp(): void
protected function tearDown(): void
public function &getCoreWithConnection(): Core
public function getDbConnection(): IDataAccessObject
protected function getDefaultStore(array $params = []): Store
public static function preparePostForStore(string $action, array $values, Store $store, mixed $primaryKeyValue = null): void
public static function prepareFiltersForStore(array $values, Store &$store): bool
public static function cleanFiltersForStore(Store &$store): bool
public static function cleanPostByStoreName(string $storeName): void
public static function cleanPostByStore(Store &$store): void
public static function preparePostByStoreName(string $storeName, array $values, mixed $primaryKeyValue = false): void
public static function prepareChildRelationPostByStoreName(string $storeName, int|string $idParent, array $values, array $parentValues = [], mixed $primaryKeyValue = false): void
public static function preparePrimaryKey(Store &$store, mixed $primaryKeyValue): void
public function getPluginMockBuilder(string $pluginName, ?array $methods = [], array $options = []): MockObject
public function getActionMockBuilder(string $actionName, array $methods = [], array $options = []): MockObject
public function doTestStore(string $storeName, array $params = [], array $values = []): void
protected function addDatabaseData(Store $store, array $values): mixed
protected function onTestInsertStoreAction(Store $store, array $values): Response
protected function onTestListStoreAction(Store $store, array $values): void
protected function onTestEditStoreAction(Store $store, array $values): void
protected function onTestRemoveStoreAction(Store $store, array $values): void
protected function onTestInfoStoreAction(Store $store, array $values): void
protected function onTestBatchInsertStoreAction(Store $store, array $values): void
protected function onTestPluginStoreAction(Store $store, array $values): void
protected function onTestCsvImportStoreAction(Store $store, array $values): void
protected function getSearch(Store $store, array $values): array
protected function getPreparedValueByType(Store $store, int|string $name, mixed $value): mixed
protected function getPreparedValues(Store $store, array $values): array
protected function getRandomString(int $length = 20): string
protected function getRandomNumber(int $min = 1, int $max = 2000000): int
public function applyInsertAction(Store $store, array $values): array
public function applyEditAction(Store $store, mixed $primaryKeyValue, array $values): array
public function applyRemoveAction(Store $store, mixed $primaryKeyValue): void
public function createStore(array $overrideOptions = [], string $storeName = STORE_DEFAULT): Store

Typed properties: FestiTestCase: protected Core $core, PluginTestCase: protected core\util\PluginWrapper $plugin.

TestUtils:

public static function getDbConnection(array $dbConfig): IDataAccessObject
public static function cleanDatabase(): void
public static function installDatabase(string $dumpPath): bool
public static function run(string $path): mixed

Calls from project code into the framework

What changed

Framework parameters have types. PHP chooses strict or coercive checking of scalar arguments by the file that makes the call. strict_types=1 in the framework files does not change how arguments from a project file are checked. So:

  • A project file without declare(strict_types=1) still has its int, float, string and bool arguments converted to the declared scalar type, where PHP can convert them (5 to '5', '5' to 5).
  • In every file, null for a parameter that is not nullable is a TypeError. So is an array or an object for a scalar parameter, a scalar for an array parameter, and a string that is not numeric for an int.
  • A project file with declare(strict_types=1) must pass the declared type. An int for a string parameter is a TypeError there.

Before, many of these parameters had no type and accepted any value.

Who is affected

Code that passes null, an array or an object to a framework parameter declared as a scalar, or a scalar to one declared as array. Project files that declare strict_types=1 and pass a scalar of another type, for example an int event type to addEventListener(string $type, ...).

Project files without strict_types=1 that pass scalars are not affected.

How to find it

Run the tests and the static analysis. The error is TypeError: Y::m(): Argument #N ($p) must be of type T, U given, called in <file> on line <line>. The file and line name the call to fix.

To list the project files where every scalar argument must match:

grep -rlE --include='*.php' --exclude-dir=vendor \
  'declare\(strict_types ?= ?1\)' .

What to do

Pass a value of the declared type. Handle null before the call.

Before, with $values that can be null and an event type that is an int:

FestiTestCase::preparePostByStoreName('orders', $values);
$store->addEventListener(self::EVENT_DONE, $listener);

After:

FestiTestCase::preparePostByStoreName('orders', $values ?? []);
$store->addEventListener((string) self::EVENT_DONE, $listener);

The cast in the second line is needed only in a file that declares strict_types=1. Both parameters had no type before.

The framework calls project code in coercive mode

This is not a change a project has to act on. It is here because the framework files are strict now, and a reader may expect the opposite.

What changed

Nothing for a project method. PHP checks the arguments of a call in the mode of the file that makes it, and the framework files declare strict_types=1. The framework therefore calls project code through Festi\Core\ProjectCallback::call(), which checks the arguments in coercive mode, as a file without strict_types does:

  • Core::call() calls a plugin method with the values a URL rule captured. These values are strings. A parameter declared int $idOrder receives 5 for '5'.
  • The same holds for event listeners, hooks, the handler methods of a handler field, cellViewHandler methods, row actions of a plugin, rule callables of a request field, getFormatValue<Format>() methods of a field class and log interceptors.
  • A value PHP cannot convert is refused with a TypeError: a non-numeric string for int, null for a parameter that is not nullable, an array for a scalar.

Two classes got a protected method for this: Entity::callProjectCode() and, in every field, callFormatMethod().

Who is affected

A project class that extends Entity (a plugin, a store, a model, an action) and already has a method named callProjectCode() with another signature, and a field class that already has a method named callFormatMethod() with another signature. PHP reports both when the class is loaded.

How to find it

grep -rnE --include='*.php' --exclude-dir=vendor \
  'function &?(callProjectCode|callFormatMethod)\(' .

What to do

Rename the project method. The framework methods are:

Entity::callProjectCode(callable $callback, array $arguments = []): mixed
AbstractFieldFormatTrait::callFormatMethod(string $methodName, mixed $value): mixed

Field models built from an array or a class

What changed

A field keeps a string attribute as the schema gives it. The getters now declare string, in a strict file. AbstractField::getCaption(): string throws a TypeError when the caption attribute holds an int. The name attribute is assigned to a string property in AbstractField::onInit() the same way.

Who is affected

Stores whose model is built from an array or from a schema class and give a string attribute, such as caption or name, as an int, a float or a bool.

Stores with an XML schema are not affected by this part: the attributes of an XML element are strings.

How to find it

Run the tests and open the stores that use an array or class schema. The error is TypeError: AbstractField::getCaption(): Return value must be of type string, int returned.

To list array and class schemas that give a number as caption or name:

grep -rnE --include='*.php' --exclude-dir=vendor \
  "'(caption|name)' *=> *[0-9]" .

What to do

Give the attribute as a string.

Before:

$field = [
    'type'    => 'text',
    'name'    => 'year',
    'caption' => 2026,
];

After:

$field = [
    'type'    => 'text',
    'name'    => 'year',
    'caption' => '2026',
];

Store key methods refuse null

CHANGELOG entry: Store, its traits, PluginStore (key types), and the store proxies (updateByPrimaryKey()).

What changed

Store::removeByPrimaryKey(int|string $primaryKeyValue): bool
Store::removeChildByPrimaryKey(int|string $primaryKeyValue): bool
Store::updateByPrimaryKey(int|string $primaryKeyValue, array $values): bool
IProxyRepository::updateByPrimaryKey(string|int|bool $primaryKeyValue, array $values): bool

Before, the key parameter of the three Store methods had no type. Now a null key is a TypeError before the method runs, so no child row is removed for it. Store::updateByPrimaryKey() also requires $values to be an array.

Who is affected

Code that calls one of these methods with a key that can be null, for example the result of a lookup that found no row.

Calls with an int or string key are not affected. In a file without strict_types=1 a float or bool key is still converted.

How to find it

grep -rnE --include='*.php' --exclude-dir=vendor \
  '(remove|removeChild|update)ByPrimaryKey\(' .

Check for each match whether the key can be null.

What to do

Before:

$row = $store->loadRow($search);
$store->removeByPrimaryKey($row['id'] ?? null);

After:

$row = $store->loadRow($search);
if ($row !== null) {
    $store->removeByPrimaryKey($row['id']);
}

Values received by reference must stay arrays

CHANGELOG entry: the store actions under src/store/action.

What changed

Actions hand some values to listeners by reference. The properties behind these references are typed ?array:

Event Target key Property Typed before
Store::EVENT_ON_LOAD_ACTION_ROWS values ListAction::$storageValues no
AbstractAction::EVENT_PREPARE_FOREIGN_FIELD_VALUES results ForeignKeyLoadAction::$_values no
Store::EVENT_ON_LOAD_ACTION_VALUES values InsertAction::$data yes

A listener that writes a string, a number, false or an object there gets TypeError: Cannot assign string to reference held by property ListAction::$storageValues of type ?array.

The values target of Store::EVENT_BEFORE_INSERT and Store::EVENT_BEFORE_UPDATE is passed on to a parameter declared array. That was already so before.

ListAction::$storageTotals, the totals target of Store::EVENT_ON_LOAD_LIST_DATA, is typed mixed and accepts any value.

Who is affected

Listeners of the first two events in the table that replace the value with something that is not an array.

Listeners that add, change or remove elements of the array are not affected.

How to find it

grep -rnE --include='*.php' --include='*.xml' --exclude-dir=vendor \
  'EVENT_ON_LOAD_ACTION_ROWS|EVENT_ON_LOAD_ACTION_VALUES|EVENT_PREPARE_FOREIGN_FIELD_VALUES|load_action_rows|load_action_values|prepare_foreign_field_values' .

Read each listener and check what it assigns to the target value.

What to do

Before:

$store->addEventListener(
    Store::EVENT_ON_LOAD_ACTION_ROWS,
    function (FestiEvent &$event) {
        $rows = &$event->getTargetValueByKey('values');
        if (!$rows) {
            $rows = false;
        }
    }
);

After:

$store->addEventListener(
    Store::EVENT_ON_LOAD_ACTION_ROWS,
    function (FestiEvent &$event) {
        $rows = &$event->getTargetValueByKey('values');
        if (!$rows) {
            $rows = [];
        }
    }
);

DGS action failures are not logged by the framework

CHANGELOG entry: "The framework no longer logs DGS action failures".

What changed

Every exception an action handles (AbstractAction::doHandleException()) is reported through Festi\Store\Event\ActionErrorEvent::EVENT_ON_ACTION_ERROR, dispatched on the store. A failing Store::EVENT_ROLLBACK listener of an insert, edit or remove action is reported through the same event. The framework writes no log entry for either. A listener of EVENT_ON_ACTION_ERROR that throws is ignored.

Who is affected

Projects that want DGS action failures in their log.

A project that upgrades from the tag 7.3.0 loses nothing: at that tag src/store wrote no log entry and Store::EVENT_ROLLBACK did not exist. Only a project that followed develop saw failing Store::EVENT_ROLLBACK listeners in the store log, through FestiUtils::doHandleCoreException().

How to find it

grep -rn --include='*.php' --exclude-dir=vendor \
  'EVENT_ON_ACTION_ERROR' .

No match means the project does not log DGS action failures.

What to do

Subscribe for every store from Core::EVENT_ON_CREATE_STORE, for example in the init.php of a plugin. Nothing to remove, so there is no "before".

After:

use Festi\Store\Event\ActionErrorEvent;

$core = Core::getInstance();

$core->addEventListener(
    Core::EVENT_ON_CREATE_STORE,
    function (FestiEvent &$event) {
        $store = &$event->getTargetValueByKey('store');

        $store->addEventListener(
            ActionErrorEvent::EVENT_ON_ACTION_ERROR,
            function (ActionErrorEvent $event) {
                FestiUtils::doHandleCoreException(
                    $event->getException(),
                    'store',
                    false
                );
            }
        );
    }
);

ActionErrorEvent also gives getActionInstance() and getActionName(). See ./docs/DGS/Events.md.

getElementName() returns an ElementName

CHANGELOG entry: IStoreField::getElementName() returns a Festi\Store\Field\ElementName.

What changed

IStoreField::getElementName(): ElementName     (before: string)
ElementName::getValue(): string
ElementName::__toString(): string
ElementName::isNameless(): bool
ElementName::getModelKey(): int|string
StoreModel::getFieldByElementName(ElementName $elementName): ?IStoreField

ElementName is a final class that implements Stringable.

Who is affected

Code that calls getElementName() and uses the result where PHP does not convert an object to a string.

Use of the result Result
echo, ., "$name", sprintf(), implode() works
string parameter in a file without strict_types=1 works
== with a string, in_array() without the strict flag works
$row[$name], isset($row[$name]), array_key_exists($name, $row) TypeError
=== with a string, in_array($name, $list, true), match false, no match
json_encode() writes {}
http_build_query() drops the value
string parameter in a file with strict_types=1 TypeError

A field class that overrides getElementName() must return an ElementName.

How to find it

grep -rn --include='*.php' --include='*.phtml' --exclude-dir=vendor \
  'getElementName()' .

What to do

Call getValue() at the sites of the last five rows of the table.

Before:

$name = $field->getElementName();
$value = $row[$name] ?? null;
$json = json_encode(['field' => $name]);

After:

$name = $field->getElementName()->getValue();
$value = $row[$name] ?? null;
$json = json_encode(['field' => $name]);

ISystemPlugin has no menu methods

CHANGELOG entry: ISystemPlugin no longer declares setActiveMenu and getActiveMenu.

What changed

ISystemPlugin does not declare setActiveMenu(string $ident): void and getActiveMenu(): ?string. ApiPlugin does not have these methods any more. They are declared by Festi\Theme\UI\IMenuStructureProvider, in the package festi-team/festi-framework-theme.

Who is affected

Code that calls setActiveMenu() or getActiveMenu() on the system plugin. When the system plugin is ApiPlugin, or a subclass that does not add the methods, the call is Error: Call to undefined method. When the system plugin has the methods, the call still works at run time. Static analysis can report it, because Core::getSystemPlugin() returns ISystemPlugin.

A system plugin class that implements ISystemPlugin and still has both methods keeps loading.

How to find it

grep -rnE --include='*.php' --include='*.phtml' --exclude-dir=vendor \
  '(set|get)ActiveMenu\(' .

What to do

Before:

$systemPlugin = Core::getInstance()->getSystemPlugin();
$systemPlugin->setActiveMenu('orders');

After:

use Festi\Theme\UI\IMenuStructureProvider;

$systemPlugin = Core::getInstance()->getSystemPlugin();
if ($systemPlugin instanceof IMenuStructureProvider) {
    $systemPlugin->setActiveMenu('orders');
}

ForeignKeyLoadAction answers with JSON

CHANGELOG entry: ForeignKeyLoadAction returns JSON for dependent field loading.

What changed

A request with ajaxChild and ajaxParent used to render the template actions/foreign_key_load.php once per child field and answer with the rendered content. The framework does not ship that template. Now the action sets the response type to Response::JSON and answers:

{
  "results": [
    {
      "fieldName": "studygroup_id",
      "childValue": "42",
      "values": [
        {"key": "Group A", "value": "42"},
        {"key": "Group B", "value": "43"}
      ]
    }
  ]
}

key is the label and value is the identifier. childValue is null when the request carries no value for the child field.

The autocomplete branch (a request with fieldName) answered with JSON before and is not changed.

Who is affected

Projects that use fields with the ajaxChild attribute and have their own theme or JavaScript that loads the child values, or their own actions/foreign_key_load.php template. The framework does not read that template any more.

The client code has to read the JSON. The commit that made this change names a companion change to dbaForeignKeyLoad in the theme kit. That code is not in this repository, so check the version of the theme the project uses.

How to find it

grep -rnE --exclude-dir=vendor --exclude-dir=node_modules \
  'ajaxChild|foreign_key_load' .
find . -name 'foreign_key_load.php' -not -path './vendor/*'

What to do

Delete the project copy of actions/foreign_key_load.php. Where the project has its own client code for dependent selects, fill each select named by fieldName from values and select childValue. The format is described in ./docs/DGS/Fields.md.

A PHP test that asserted on the rendered script changes like this.

Before:

$store->onRequest($response);
$html = $response->content;

After:

$store->onRequest($response);
$fields = $response->results;
$options = $fields[0]['values'];

Removed classes, removed methods and wider visibility

These changes are not marked (BREAKING) in ./CHANGELOG.md. They were found by comparing src/ with the tag 7.3.0.

What changed

Before Now
abstract class AbstractDisplayAction extends AbstractAction removed; its methods are in the trait DisplayActionTrait
InsertAction, ListAction, ColumnsAction, CsvImportAction, RelationAction extend AbstractDisplayAction they extend AbstractAction
RemoveAction extends InfoAction RemoveAction extends EditAction
abstract class AbstractSelectField extends AbstractField removed
SelectField, ForeignKeyField extend AbstractSelectField they extend AbstractField
ComboField, SetField removed; the field types combo and set do not exist
ISystemPlugin::onBind(), ApiPlugin::onBind() removed
AbstractField::displayRow(string $value): string removed
AbstractField::getFilter($value): string removed
FileField::copy(), FileField::isUploaded() removed
ApiPlugin::__getDefaultUrlRules(): array ApiPlugin::getDefaultUrlRules(): array
protected Store::setTotalCount(?int $total): void public
protected AbstractField::getViewValue(mixed $value): ?string public
protected AbstractField::getFormatedValue(mixed $value, string $format): string public
protected StoreModel::getActionAttributesOptions(): array public

Who is affected

Classes that extend a removed class, instanceof checks against one, schemas with type="combo" or type="set", code that calls a removed method, and classes that override one of the four methods as protected.

How to find it

The class-load check reports the classes. To find the other uses:

grep -rnE --include='*.php' --include='*.xml' --exclude-dir=vendor \
  'AbstractDisplayAction|AbstractSelectField|ComboField|SetField|type="(combo|set)"|instanceof InfoAction' .
grep -rnE --include='*.php' --include='*.phtml' --exclude-dir=vendor \
  '(onBind|displayRow|getFilter|__getDefaultUrlRules)\(|function (copy|isUploaded|setTotalCount|getViewValue|getFormatedValue|getActionAttributesOptions)\(' .

What to do

Extend AbstractAction or AbstractField. Declare the four methods public in the override.

Before:

class ReportAction extends AbstractDisplayAction
{
    public function getActionName(): string
    {
        return 'report';
    }
}

class MoneyField extends NumberField
{
    protected function getViewValue(mixed $value): ?string
    {
        return parent::getViewValue($value);
    }
}

After:

use Festi\Store\Action\DisplayActionTrait;

class ReportAction extends AbstractAction
{
    use DisplayActionTrait;

    public function getActionName(): string
    {
        return 'report';
    }
}

class MoneyField extends NumberField
{
    public function getViewValue(mixed $value): ?string
    {
        return parent::getViewValue($value);
    }
}

Utility methods take value objects

CHANGELOG entry: ImageUtils::setImageResize(), setImageResizeMagic(), createWatermark(), Curl::getUrl() and ApiUtils::send() take value objects.

What changed

Before Now
ImageUtils::setImageResize(string $outfile, string $infile, ?int $neww, ?int $newh = null, array $options = [], int $permission = self::DEFAULT_FILE_PERMISSION): bool ImageUtils::setImageResize(string $outfile, string $infile, ImageSize $size, ?ImageOutput $output = null): bool
ImageUtils::setImageResizeMagic(string $outfile, string $infile, int\|float\|string $neww, int\|float\|string\|null $newh = null, bool $isCrop = false): void ImageUtils::setImageResizeMagic(string $outfile, string $infile, ImageSize $size, bool $isCrop = false): void
ImageUtils::createWatermark(\GdImage $mainImgObj, string $text, string $font, int $r = 0, int $g = 0, int $b = 255, int $alphaLevel = 95): \GdImage\|false ImageUtils::createWatermark(\GdImage $mainImgObj, Watermark $watermark): \GdImage\|false
Curl::getUrl(string $url, array\|string\|bool\|null $postParams = false, string\|bool $proxy = false, string\|bool $proxyPswd = false, bool $isIgnoreErrors = false): string\|bool Curl::getUrl(string $url, array\|string\|bool\|null $postParams = false, ?CurlProxy $proxy = null, bool $isIgnoreErrors = false): string\|bool
ApiUtils::send(string $url, array\|string\|bool\|null $data = false, string\|bool\|null $requestMethod = false, array\|bool\|null $headers = false, mixed &$responseHeaders = null): mixed ApiUtils::send(string $url, ApiRequest $request): ApiResponse

The new classes are in the namespace Festi\Core\Utils. Their values are set by the constructor and read with getters:

ImageSize::__construct(int|float|string|null $width, int|float|string|null $height = null)
ImageOutput::__construct(int $quality = 100, ?Watermark $watermark = null, int $permission = ImageUtils::DEFAULT_FILE_PERMISSION)
Watermark::__construct(string $text, string $font, ?WatermarkColor $color = null)
WatermarkColor::__construct(int $red = 0, int $green = 0, int $blue = 255, int $alpha = 95)
CurlProxy::__construct(string $host, string $credentials = '')
ApiRequest::__construct(array|string|bool|null $data = false, string|bool|null $method = false, array|bool|null $headers = false)
ApiResponse::getData(): mixed
ApiResponse::getHeaders(): array

The methods do the same work as before for the same values: the same resize arithmetic, the same JPEG, the same file mode 0644, the same watermark color, the same cURL options and the same exceptions.

ImageSize takes a number or a numeric string for a side. A string that is not a number, such as '100px' or '', is refused by the constructor with a SystemException that names the side: Image width must be a number, "100px" given. Before, setImageResize() refused such a value with a TypeError on its ?int parameter. ApiResponse::getData() is the value send() returned before, and ApiResponse::getHeaders() is the value the fifth argument received.

These calls do not change:

  • Curl::getUrl() with one or two arguments.
  • ApiUtils::sendGet(), sendPost(), sendPut() and sendDelete(). They return the decoded body as before.
  • OpenApiProxy::send() and OpenApiProxy::getResponseHeaders().

Who is affected

Code that calls one of the five methods with the old arguments, and classes that extend Curl, ApiUtils or ImageUtils and override one of them.

PHP ignores arguments a method does not declare, but none of the old calls runs with a changed meaning. In each new signature the first changed position takes an object, and PHP never converts a number, a string, false or null to an object, with or without strict_types=1:

Old call Result
ImageUtils::setImageResize($out, $in, 320), also with more arguments TypeError: ImageUtils::setImageResize(): Argument #3 ($size) must be of type Festi\Core\Utils\ImageSize, int given
ImageUtils::setImageResizeMagic($out, $in, '320', '240', true) TypeError: ImageUtils::setImageResizeMagic(): Argument #3 ($size) must be of type Festi\Core\Utils\ImageSize, string given
ImageUtils::createWatermark($image, 'DRAFT', $font), also with colors TypeError: ImageUtils::createWatermark(): Argument #2 ($watermark) must be of type Festi\Core\Utils\Watermark, string given
$curl->getUrl($url), $curl->getUrl($url, $body) works as before
$curl->getUrl($url, $body, false, false, true) TypeError: Curl::getUrl(): Argument #3 ($proxy) must be of type ?Festi\Core\Utils\CurlProxy, false given (PHP 8.1 and 8.2 print bool given)
$curl->getUrl($url, $body, '10.0.0.1:3128', 'user:secret') the same TypeError, with string given
ApiUtils::send($url) ArgumentCountError: Too few arguments to function ApiUtils::send(), 1 passed in <project file> and exactly 2 expected
ApiUtils::send($url, $data, 'POST', $headers, $responseHeaders), also with fewer arguments TypeError: ApiUtils::send(): Argument #2 ($request) must be of type Festi\Core\Utils\ApiRequest, array given
A class that overrides one of the five methods with the old parameters Fatal error: Declaration of X::getUrl(...) must be compatible with Curl::getUrl(...), reported by the class-load check

The errors appear only when the line runs. Use the commands below to find calls that tests and requests do not reach.

How to find it

grep -rnE --include='*.php' --include='*.phtml' --exclude-dir=vendor \
  'setImageResize(Magic)?\(|createWatermark\(|ApiUtils::send\(' .

Every match of this command needs a change.

getUrl( is also the name of the url methods of Core and of the plugins. This command looks only in files that use Curl, and prints the getUrl() calls that pass three or more arguments or continue on the next line:

grep -rlE --include='*.php' --exclude-dir=vendor \
  'new \\?Curl\(|extends \\?Curl|Curl \$' . \
  | xargs grep -HnE \
  'getUrl\(([^()]|\([^()]*\))*,([^()]|\([^()]*\))*,|getUrl\($'

A match needs a change when the object is a Curl and the call has more than two arguments.

What to do

Import the classes a file uses:

use Festi\Core\Utils\ApiRequest;
use Festi\Core\Utils\CurlProxy;
use Festi\Core\Utils\ImageOutput;
use Festi\Core\Utils\ImageSize;
use Festi\Core\Utils\Watermark;
use Festi\Core\Utils\WatermarkColor;

ImageUtils::setImageResize(): put the width and the height into new ImageSize($width, $height), and when the call passed $options or $permission, pass new ImageOutput($quality, $watermark, $permission) as the fourth argument, where $watermark is new Watermark($options['watermark'], $options['font']) or null.

Before:

$options = [
    'quality'   => 85,
    'watermark' => 'DRAFT',
    'font'      => $fontPath,
];

ImageUtils::setImageResize($thumbPath, $uploadPath, 320);
ImageUtils::setImageResize($smallPath, $uploadPath, null, 240);
ImageUtils::setImageResize($previewPath, $uploadPath, 800, 600, $options, 0640);

After:

$watermark = new Watermark('DRAFT', $fontPath);
$output = new ImageOutput(85, $watermark, 0640);
$previewSize = new ImageSize(800, 600);

ImageUtils::setImageResize($thumbPath, $uploadPath, new ImageSize(320));
ImageUtils::setImageResize($smallPath, $uploadPath, new ImageSize(null, 240));
ImageUtils::setImageResize($previewPath, $uploadPath, $previewSize, $output);

A call that changed only the file mode can name the argument: new ImageOutput(permission: 0640).

ImageUtils::setImageResizeMagic(): put the third and the fourth argument into new ImageSize(...); $isCrop moves from the fifth position to the fourth. Numeric strings from the config are accepted as before.

Before:

ImageUtils::setImageResizeMagic($thumbPath, $uploadPath, $config['width']);
ImageUtils::setImageResizeMagic($coverPath, $uploadPath, '640', '360', true);

After:

$thumbSize = new ImageSize($config['width']);
$coverSize = new ImageSize('640', '360');

ImageUtils::setImageResizeMagic($thumbPath, $uploadPath, $thumbSize);
ImageUtils::setImageResizeMagic($coverPath, $uploadPath, $coverSize, true);

ImageUtils::createWatermark(): put the text and the font into new Watermark($text, $font), and when the call passed colors, pass new WatermarkColor($r, $g, $b, $alphaLevel) as its third argument.

Before:

$image = ImageUtils::createWatermark($image, 'DRAFT', $fontPath);
$image = ImageUtils::createWatermark($image, 'SOLD', $fontPath, 255, 0, 0, 60);

After:

$draft = new Watermark('DRAFT', $fontPath);
$sold = new Watermark('SOLD', $fontPath, new WatermarkColor(255, 0, 0, 60));

$image = ImageUtils::createWatermark($image, $draft);
$image = ImageUtils::createWatermark($image, $sold);

Curl::getUrl(): replace the third and the fourth argument by one argument, null when they were false, or new CurlProxy($proxy, $proxyPswd); $isIgnoreErrors moves from the fifth position to the fourth. In the common call this is the replacement of false, false, by null,.

Before:

$options = [
    CURLOPT_FAILONERROR => 0,
    CURLOPT_TIMEOUT     => 30,
];
$curl = new Curl($options);

$page = $curl->getUrl($url);
$response = $curl->getUrl($url, $body, false, false, true);
$remote = $curl->getUrl($url, $body, '10.0.0.1:3128', 'user:secret');

After:

$options = [
    CURLOPT_FAILONERROR => 0,
    CURLOPT_TIMEOUT     => 30,
];
$curl = new Curl($options);

$page = $curl->getUrl($url);
$response = $curl->getUrl($url, $body, null, true);
$proxy = new CurlProxy('10.0.0.1:3128', 'user:secret');
$remote = $curl->getUrl($url, $body, $proxy);

The call can also name the argument and leave the proxy out: $curl->getUrl($url, $body, isIgnoreErrors: true). As before, a Curl object keeps the proxy for its next requests.

ApiUtils::send(): put the second, third and fourth argument, unchanged and in the same order, into new ApiRequest(...), read the decoded body with getData(), and read the headers the fifth argument received with getHeaders(). A call that passed only the url passes new ApiRequest().

Before:

$headers = [
    'Content-type: application/json',
    'Access-Token: '.$token,
];

$orders = ApiUtils::send($url);
$order = ApiUtils::send($url, $values, 'POST', $headers, $responseHeaders);
$total = $responseHeaders['x-total-count'][0] ?? null;

After:

$headers = [
    'Content-type: application/json',
    'Access-Token: '.$token,
];

$orders = ApiUtils::send($url, new ApiRequest())->getData();

$request = new ApiRequest($values, 'POST', $headers);
$response = ApiUtils::send($url, $request);
$order = $response->getData();
$total = $response->getHeaders()['x-total-count'][0] ?? null;

A class that overrides one of the five methods declares the new parameters and the new return type.

Before:

class ShopApi extends ApiUtils
{
    public static function send(
        string $url,
        array|string|bool|null $data = false,
        string|bool|null $requestMethod = false,
        array|bool|null $headers = false,
        mixed &$responseHeaders = null
    ): mixed
    {
        $headers = ['Access-Token: '.static::getToken()];

        return parent::send($url, $data, $requestMethod, $headers);
    }
}

After:

use Festi\Core\Utils\ApiRequest;
use Festi\Core\Utils\ApiResponse;

class ShopApi extends ApiUtils
{
    public static function send(string $url, ApiRequest $request): ApiResponse
    {
        $headers = ['Access-Token: '.static::getToken()];

        $signed = new ApiRequest(
            $request->getBody(),
            $request->getMethod(),
            $headers
        );

        return parent::send($url, $signed);
    }
}

Proxy methods take value objects

CHANGELOG entry: updateForeignTableValues() and getDataForUpdateForeignTableValues() of the SQL proxies and loadRemoteListValues() and getListValuesEndpointUrl() of OpenApiProxy take value objects.

What changed

All four methods are protected. SqlProxy gets the first two from the trait SqlProxyRepository, and MssqlProxy overrides the second one.

Before Now
SqlProxy::updateForeignTableValues(string $tableName, string $foreignTableName, array $foreignValues, array &$values, ?array $currentRow = null): bool\|string SqlProxy::updateForeignTableValues(ForeignTableValues $foreign, array &$values): bool\|string
SqlProxy::getDataForUpdateForeignTableValues(string $tableName, string $foreignTableName, array $values, array $foreignValues, ?array $currentRow = null): array, also in MssqlProxy SqlProxy::getDataForUpdateForeignTableValues(ForeignTableValues $foreign): array, also in MssqlProxy
OpenApiProxy::loadRemoteListValues(string $storeName, array $columns, array $conditions, array $orderBy, array $limit, bool $isAllColumns = false): array OpenApiProxy::loadRemoteListValues(string $storeName, ListValuesQuery $query): array
OpenApiProxy::getListValuesEndpointUrl(string $baseUrl, array $columns, array $conditions, array $orderBy, array $limit): string OpenApiProxy::getListValuesEndpointUrl(string $baseUrl, ListValuesQuery $query): string

The new classes are in the namespace Festi\Store\Proxy. Their values are set by the constructor and read with getters; they have no setters:

ForeignTableValues::__construct(string $tableName, string $foreignTableName, array $foreignValues, ?array $currentRow = null)
ForeignTableValues::getTableName(): string
ForeignTableValues::getForeignTableName(): string
ForeignTableValues::getForeignValues(): array
ForeignTableValues::getCurrentRow(): ?array
ListValuesQuery::__construct(array $columns, array $conditions, array $orderBy, array $limit, bool $isAllColumns = false)
ListValuesQuery::getColumns(): array
ListValuesQuery::getConditions(): array
ListValuesQuery::getOrderBy(): array
ListValuesQuery::getLimit(): array
ListValuesQuery::isAllColumns(): bool

ForeignTableValues is what a store with several tables writes to one foreign table: the main table of the store, the foreign table a <route> joins to it, the values for the row of the foreign table, and the row of the main table as it is stored now (null or empty for a new row). ListValuesQuery is the list a store asks the remote API for.

In both SQL methods the object is the first parameter. updateForeignTableValues() takes $values, the values of the main row, as the second: it still receives them by reference and still writes the key of the foreign row into them. getDataForUpdateForeignTableValues() no longer takes the values of the main row, because the framework never read them there: the object is its only parameter. Before, the two methods took $values and $foreignValues in opposite orders; now $foreignValues has one place, inside the object.

The methods do the same work as before for the same values: the same queries, the same request url, the same result and the same exceptions.

These do not change:

  • The public methods of the proxies: loadListValues(), insert(), updateByPrimaryKey() and the rest of IProxy and IProxyRepository.
  • Schemas, routes and the Store::EVENT_PREPARE_REPOSITORY_VALUES event.
  • The other protected methods of the proxies, among them appendListValuesLimitRequestParam(), appendListValuesOrderByRequestParam(), convertConditionsToRequest(), getListValuesOrderBy() and getQueryLimit().

Who is affected

A class of the project that extends MysqlProxy, PgsqlProxy, MssqlProxy, SqlProxy or core\store\proxy\OpenApiProxy and overrides or calls one of the four methods. A proxy that does neither needs no change, and code outside a proxy class cannot call them.

None of the old overrides and calls runs with a changed meaning. In each new signature the first changed position takes an object, and PHP never converts a string or an array to an object, with or without strict_types=1:

Old code Result
A class that overrides updateForeignTableValues() with the old parameters Fatal error: Declaration of X::updateForeignTableValues(string $tableName, string $foreignTableName, array $foreignValues, array &$values, ?array $currentRow = null): string\|bool must be compatible with SqlProxy::updateForeignTableValues(Festi\Store\Proxy\ForeignTableValues $foreign, array &$values): string\|bool, reported by the class-load check
A class that overrides getDataForUpdateForeignTableValues() with the old parameters Fatal error: Declaration of X::getDataForUpdateForeignTableValues(string $tableName, string $foreignTableName, array $values, array $foreignValues, ?array $currentRow = null): array must be compatible with SqlProxy::getDataForUpdateForeignTableValues(Festi\Store\Proxy\ForeignTableValues $foreign): array; the message names MssqlProxy when X extends it
A class that overrides loadRemoteListValues() with the old parameters Fatal error: Declaration of X::loadRemoteListValues(string $storeName, array $columns, array $conditions, array $orderBy, array $limit, bool $isAllColumns = false): array must be compatible with core\store\proxy\OpenApiProxy::loadRemoteListValues(string $storeName, Festi\Store\Proxy\ListValuesQuery $query): array
A class that overrides getListValuesEndpointUrl() with the old parameters Fatal error: Declaration of X::getListValuesEndpointUrl(string $baseUrl, array $columns, array $conditions, array $orderBy, array $limit): string must be compatible with core\store\proxy\OpenApiProxy::getListValuesEndpointUrl(string $baseUrl, Festi\Store\Proxy\ListValuesQuery $query): string
$this->updateForeignTableValues($tableName, $foreignTableName, $foreignValues, $values, $currentRow), also without the last argument TypeError: SqlProxy::updateForeignTableValues(): Argument #1 ($foreign) must be of type Festi\Store\Proxy\ForeignTableValues, string given
The same call with a literal or an expression as the second argument, such as 'shop_settings' Error: SqlProxy::updateForeignTableValues(): Argument #2 ($values) could not be passed by reference
$this->getDataForUpdateForeignTableValues($tableName, $foreignTableName, $values, $foreignValues, $currentRow), also parent:: TypeError: SqlProxy::getDataForUpdateForeignTableValues(): Argument #1 ($foreign) must be of type Festi\Store\Proxy\ForeignTableValues, string given; the message names MssqlProxy in a class that extends it
$this->loadRemoteListValues($storeName, $columns, $conditions, $orderBy, $limit), also with $isAllColumns TypeError: core\store\proxy\OpenApiProxy::loadRemoteListValues(): Argument #2 ($query) must be of type Festi\Store\Proxy\ListValuesQuery, array given
$this->getListValuesEndpointUrl($url, $columns, $conditions, $orderBy, $limit) TypeError: core\store\proxy\OpenApiProxy::getListValuesEndpointUrl(): Argument #2 ($query) must be of type Festi\Store\Proxy\ListValuesQuery, array given

An old call passes more arguments than the new signature declares, so none of them ends in an ArgumentCountError. The fatal error stops the class from loading, which stops every store that uses the proxy. The TypeError and the Error appear only when the line runs: a call to a SQL method runs when a store with a <route> saves a field of the joined table, and a call to an OpenApiProxy method runs when a list is loaded.

How to find it

grep -rnE --include='*.php' --exclude-dir=vendor \
  '(updateForeignTableValues|getDataForUpdateForeignTableValues|loadRemoteListValues|getListValuesEndpointUrl)\(' .

Every match in a class of the project needs a change: a line with function is an override, the other lines are calls. A copy of the framework that is kept inside the project matches too; it is replaced by the new version and not edited.

What to do

Import the class a file uses:

use Festi\Store\Proxy\ForeignTableValues;
use Festi\Store\Proxy\ListValuesQuery;

updateForeignTableValues(): put the first three arguments and the fifth, in this order, into new ForeignTableValues($tableName, $foreignTableName, $foreignValues, $currentRow) and pass it as the first argument; $values moves from the fourth position to the second and stays by reference.

getDataForUpdateForeignTableValues(): put the first, the second, the fourth and the fifth argument into the same new ForeignTableValues($tableName, $foreignTableName, $foreignValues, $currentRow) and pass it as the only argument. The third argument, $values, is no longer passed: the values of the main row do not reach this method. An override that needs them also overrides updateForeignTableValues(), which still receives $values and is the only method of the framework that calls this one, and keeps them, for example in a property, before it calls its parent.

Calls, before:

$result = $this->updateForeignTableValues(
    $tableName,
    $settingsTable,
    $settings,
    $values,
    $currentRow
);

$data = $this->getDataForUpdateForeignTableValues(
    $tableName,
    $settingsTable,
    $values,
    $settings,
    $currentRow
);

After:

$foreign = new ForeignTableValues(
    $tableName,
    $settingsTable,
    $settings,
    $currentRow
);

$result = $this->updateForeignTableValues($foreign, $values);

$data = $this->getDataForUpdateForeignTableValues($foreign);

A call that had no $currentRow leaves the fourth argument of the constructor out.

An override declares the new parameters and reads the tables, the foreign values and the current row with the getters. The object cannot be changed: an override that changes the foreign values builds a new one for its parent.

Before:

class ShopProxy extends MysqlProxy
{
    protected function updateForeignTableValues(
        string $tableName,
        string $foreignTableName,
        array $foreignValues,
        array &$values,
        ?array $currentRow = null
    ): bool|string
    {
        $result = parent::updateForeignTableValues(
            $tableName,
            $foreignTableName,
            $foreignValues,
            $values,
            $currentRow
        );

        if ($foreignTableName === 'shop_settings') {
            $values['mdate'] = date('Y-m-d H:i:s');
        }

        return $result;
    }

    protected function getDataForUpdateForeignTableValues(
        string $tableName,
        string $foreignTableName,
        array $values,
        array $foreignValues,
        ?array $currentRow = null
    ): array
    {
        $foreignValues['id_author'] = $this->getAuthorId();

        return parent::getDataForUpdateForeignTableValues(
            $tableName,
            $foreignTableName,
            $values,
            $foreignValues,
            $currentRow
        );
    }

    private function getAuthorId(): int
    {
        return 1;
    }
}

After:

use Festi\Store\Proxy\ForeignTableValues;

class ShopProxy extends MysqlProxy
{
    protected function updateForeignTableValues(
        ForeignTableValues $foreign,
        array &$values
    ): bool|string
    {
        $result = parent::updateForeignTableValues($foreign, $values);

        if ($foreign->getForeignTableName() === 'shop_settings') {
            $values['mdate'] = date('Y-m-d H:i:s');
        }

        return $result;
    }

    protected function getDataForUpdateForeignTableValues(
        ForeignTableValues $foreign
    ): array
    {
        $foreignValues = $foreign->getForeignValues();
        $foreignValues['id_author'] = $this->getAuthorId();

        $changed = new ForeignTableValues(
            $foreign->getTableName(),
            $foreign->getForeignTableName(),
            $foreignValues,
            $foreign->getCurrentRow()
        );

        return parent::getDataForUpdateForeignTableValues($changed);
    }

    private function getAuthorId(): int
    {
        return 1;
    }
}

loadRemoteListValues(): keep $storeName and put the other arguments, unchanged and in the same order, into new ListValuesQuery($columns, $conditions, $orderBy, $limit, $isAllColumns).

getListValuesEndpointUrl(): keep $baseUrl and put the other four arguments, unchanged and in the same order, into new ListValuesQuery($columns, $conditions, $orderBy, $limit).

Calls, before:

$rows = $this->loadRemoteListValues(
    'orders',
    $columns,
    $conditions,
    $orderBy,
    $limit,
    true
);

$url = $this->getListValuesEndpointUrl(
    $baseUrl,
    $columns,
    $conditions,
    $orderBy,
    $limit
);

After:

$query = new ListValuesQuery($columns, $conditions, $orderBy, $limit, true);

$rows = $this->loadRemoteListValues('orders', $query);

$url = $this->getListValuesEndpointUrl($baseUrl, $query);

An override declares the two parameters and reads the columns, the conditions, the order, the page and the all-columns flag with the getters.

Before:

use core\store\proxy\OpenApiProxy;

abstract class ShopApiProxy extends OpenApiProxy
{
    protected function loadRemoteListValues(
        string $storeName,
        array $columns,
        array $conditions,
        array $orderBy,
        array $limit,
        bool $isAllColumns = false
    ): array
    {
        $conditions['id_shop'] = 7;

        return parent::loadRemoteListValues(
            $storeName,
            $columns,
            $conditions,
            $orderBy,
            $limit,
            $isAllColumns
        );
    }

    protected function getListValuesEndpointUrl(
        string $baseUrl,
        array $columns,
        array $conditions,
        array $orderBy,
        array $limit
    ): string
    {
        $url = parent::getListValuesEndpointUrl(
            $baseUrl,
            $columns,
            $conditions,
            $orderBy,
            $limit
        );

        return $url.'&fields='.join(',', $columns);
    }
}

After:

use core\store\proxy\OpenApiProxy;
use Festi\Store\Proxy\ListValuesQuery;

abstract class ShopApiProxy extends OpenApiProxy
{
    protected function loadRemoteListValues(
        string $storeName,
        ListValuesQuery $query
    ): array
    {
        $conditions = $query->getConditions();
        $conditions['id_shop'] = 7;

        $shopQuery = new ListValuesQuery(
            $query->getColumns(),
            $conditions,
            $query->getOrderBy(),
            $query->getLimit(),
            $query->isAllColumns()
        );

        return parent::loadRemoteListValues($storeName, $shopQuery);
    }

    protected function getListValuesEndpointUrl(
        string $baseUrl,
        ListValuesQuery $query
    ): string
    {
        $url = parent::getListValuesEndpointUrl($baseUrl, $query);

        return $url.'&fields='.join(',', $query->getColumns());
    }
}

Exception constructors take an ExceptionContext

CHANGELOG entry: the constructors of SystemException and FieldException take an ExceptionContext.

What changed

Before Now
SystemException::__construct(?string $message = "", int $code = 0, mixed $data = false, mixed $source = null, string\|false\|null $displayMessage = false, ?Throwable $previous = null) SystemException::__construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string\|false\|null $displayMessage = false, ?Throwable $previous = null)
FieldException::__construct(string $message, ?string $selector = null, int $code = 0, mixed $data = false, mixed $source = null, ?string $displayMessage = null, ?Throwable $previous = null) FieldException::__construct(string $message, ?string $selector = null, int $code = 0, ExceptionContext $context = new ExceptionContext(), ?Throwable $previous = null)

$data and $source are now one object, at the position $data had. The new class is in the namespace Festi\Core\Exception. Its values are set by the constructor and read with getters; it has no setters:

ExceptionContext::__construct(mixed $data = false, mixed $source = null)
ExceptionContext::getData(): ?array
ExceptionContext::getLabel(): mixed
ExceptionContext::getSource(): mixed

$data and $source mean what the third and the fourth parameter of SystemException meant: an array given as $data is the data of the exception, any other value is its label, and $source is the object the exception is about, such as a store or an action.

The classes that have no constructor of their own take the same parameters as SystemException: StoreException, Festi\Core\Exception\StoreRuleException, MoLocaleException, Festi\Store\Exception\InvalidStoreSchemaException, and every project class that extends one of them without declaring a constructor. PermissionsException declares a constructor with these same five parameters: see The last untyped members have native types.

FieldException has no $displayMessage parameter. The message of a field error is the message shown to the user, as it was before when the parameter was left out; setDisplayMessage() sets another one.

$context does not accept null. That is on purpose: with a nullable parameter the old call new SystemException($message, 0, null, null, $displayMessage) would run and show another message than before.

One behaviour changes. NotFoundException::__construct(?string $message = "", int $code = 0, ?Throwable $previous = null) passed $previous to its parent as $data: getPrevious() returned null and getLabel() returned the exception. Now getPrevious() returns the exception and getLabel() returns null. The signature is the same.

These do not change:

  • A call with no argument, with a message, or with a message and a code: new SystemException($message), new SystemException($message, $code). For FieldException, a call with a message, a selector and a code.
  • The named arguments message, code and previous of both classes, selector of FieldException, and displayMessage of SystemException: new SystemException($message, displayMessage: $message) gives the same exception as before.
  • The constructors of ApiException (message, code, data, source, display message) and StoreActionException (message, action, code, previous), and what they store.
  • Every other method: getData(), getLabel(), getSource(), setSource(), hasSource(), getDisplayMessage(), setDisplayMessage(), hasDisplayMessage(), getSelector(), getAction(). An exception built from the same values answers them with the same results.

Who is affected

Code that creates one of these exceptions with a third positional argument (a fourth for FieldException), or with the named argument data or source, and a project exception whose constructor passes such arguments to parent::__construct().

None of the old calls runs with a changed meaning. No value an old call could pass as $data is an ExceptionContext, so PHP stops at that argument, with or without strict_types=1:

Old call Result
new SystemException(), new SystemException($message), new SystemException($message, $code) Works as before
new SystemException($message, displayMessage: $shown), also with a code Works as before
new SystemException($message, previous: $exception) Works as before
new SystemException($message, 0, false, null, $shown), also with a source and with $previous as the sixth argument TypeError: SystemException::__construct(): Argument #3 ($context) must be of type Festi\Core\Exception\ExceptionContext, false given (PHP 8.1 and 8.2 print bool given)
new SystemException($message, 0, null, null, $shown, $exception) The same TypeError, ending in null given
new SystemException($message, 0, $data) with an array, also followed by a source, a display message and $previous The same TypeError, ending in array given
new SystemException($message, 0, 'Label') The same TypeError, ending in string given
new SystemException($message, 0, $exception) The same TypeError, ending in the class of the exception, such as RuntimeException given
new SystemException($message, 0, $level, $file, $line) and any other value at the third position The same TypeError, ending in the type of that value, such as int given
new SystemException(message: $message, data: $label) Error: Unknown named parameter $data
new SystemException($message, source: $store) Error: Unknown named parameter $source
The same calls on StoreException and the other classes without a constructor of their own The same errors; the message names SystemException::__construct()
The same calls on PermissionsException The same errors; the message names PermissionsException::__construct()
parent::__construct($message, 0, false, null, $message) in a project exception The same TypeError, when the exception is created
parent::__construct($message, $code, $previous) in a project exception The same TypeError, ending in null given when there is no previous exception
parent::__construct($message, displayMessage: $message) in a project exception Works as before
new FieldException($message), new FieldException($message, $selector), new FieldException($message, $selector, $code) Works as before
new FieldException($message, $selector, previous: $exception) Works as before
new FieldException($message, $selector, 0, false) and every longer call TypeError: FieldException::__construct(): Argument #4 ($context) must be of type Festi\Core\Exception\ExceptionContext, false given (PHP 8.1 and 8.2 print bool given); it ends in null given, array given or string given for these values
new FieldException($message, $selector, displayMessage: $shown) Error: Unknown named parameter $displayMessage
new FieldException($message, $selector, data: $data), also source: Error: Unknown named parameter $data, or $source
new ApiException($message, $code, $data, $source, $shown) and every shorter call Works as before
new StoreActionException($message, $action, $code, $previous) and every shorter call Works as before
new NotFoundException($message, $code, $exception) Works; $exception is now the previous exception and no longer the label

PHP adds , called in <file> on line <N> to each TypeError.

A constructor is not compared with the constructor of its parent, so the class-load check does not report a project exception that declares the old parameters. Each error appears only when the line runs, which is when the exception is created. These lines are error paths that tests often do not reach, so use the script below and do not wait for the error.

How to find it

Save this as exception-calls.php in the project root:

<?php
// Usage: php exception-calls.php <dir> [<dir> ...]
// Prints every call of the SystemException or FieldException constructor
// that the new signatures refuse: `new X(...)` and `parent::__construct(...)`
// with a third positional argument (a fourth for FieldException) or with
// the named argument data or source (also displayMessage for
// FieldException). X is one of the two classes or a class that inherits
// its constructor. A call whose argument at that position is written
// `new ExceptionContext(...)` or `$context` is taken as changed already.

const NAMES = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_STATIC];
const OPEN = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES, T_ATTRIBUTE];
const SKIP = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];

function shortName(string $name): string
{
    return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}

function text(mixed $token): string
{
    return is_array($token) ? $token[1] : $token;
}

function isToken(mixed $token, int ...$kinds): bool
{
    return is_array($token) && in_array($token[0], $kinds, true);
}

function readArguments(array $tokens, int $index): array
{
    $arguments = [];
    $current = '';
    $depth = 0;

    for ($count = count($tokens); $index < $count; $index++) {
        $token = $tokens[$index];
        $text = text($token);
        $isOpen = isToken($token, ...OPEN) ||
            in_array($token, ['(', '[', '{'], true);
        $isClose = in_array($token, [')', ']', '}'], true);
        $depth += $isOpen ? 1 : ($isClose ? -1 : 0);

        if ($depth === 0) {
            break;
        }
        if ($text === ',' && $depth === 1) {
            $arguments[] = $current;
            $current = '';
        } else if ($depth > 1 || !$isOpen) {
            $current .= $text;
        }
    }
    if ($current !== '') {
        $arguments[] = $current;
    }

    return $arguments;
}

function readSource(string $path, array &$classes, array &$calls): void
{
    $source = file_get_contents($path);
    if (stripos($source, 'Exception') === false) {
        return;
    }

    $isCode = fn($token) => !isToken($token, ...SKIP);
    $tokens = array_values(array_filter(token_get_all($source), $isCode));
    $tokens = array_merge($tokens, ['', '', '', '']);
    $class = null;

    foreach ($tokens as $index => $token) {
        $next = $tokens[$index + 1] ?? '';
        $after = $tokens[$index + 2] ?? '';
        $before = $tokens[$index - 1] ?? '';

        if (isToken($token, T_CLASS) && isToken($next, T_STRING) &&
            !isToken($before, T_DOUBLE_COLON)) {
            $class = $next[1];
            $parent = isToken($after, T_EXTENDS) ? $tokens[$index + 3] : '';
            $classes[$class] = [shortName(text($parent)), false];
        } else if (isToken($token, T_FUNCTION) && $class !== null &&
            strtolower(text($next)) === '__construct') {
            $classes[$class][1] = true;
        } else if (isToken($token, T_NEW) && isToken($next, ...NAMES) &&
            $after === '(') {
            $name = shortName($next[1]);
            $isOwn = in_array(strtolower($name), ['self', 'static'], true);
            $calls[] = [
                $isOwn ? $class : $name,
                readArguments($tokens, $index + 2),
                $path.':'.$token[2].'  new '.$next[1].'(...)',
            ];
        } else if (strtolower(text($token)) === 'parent' && $class !== null &&
            isToken($next, T_DOUBLE_COLON) &&
            strtolower(text($after)) === '__construct') {
            $calls[] = [
                $classes[$class][0],
                readArguments($tokens, $index + 3),
                $path.':'.$token[2].'  parent::__construct(...) in '.$class,
            ];
        }
    }
}

function findConstructor(?string $class, array $classes): ?string
{
    $framework = [
        'SystemException'             => 'SystemException',
        'StoreException'              => 'SystemException',
        'PermissionsException'        => 'SystemException',
        'StoreRuleException'          => 'SystemException',
        'MoLocaleException'           => 'SystemException',
        'InvalidStoreSchemaException' => 'SystemException',
        'FieldException'              => 'FieldException',
    ];

    for ($depth = 0; $class && $depth < 20; $depth++) {
        if (isset($framework[$class])) {
            return $framework[$class];
        }
        if (!isset($classes[$class]) || $classes[$class][1]) {
            return null;
        }
        $class = $classes[$class][0];
    }

    return null;
}

function findReason(string $constructor, array $arguments): ?string
{
    $isField = $constructor === 'FieldException';
    $refused = $isField ? 'data|source|displayMessage' : 'data|source';
    $limit = $isField ? 3 : 2;
    $changed = '/^(new[A-Za-z_\\\\]*ExceptionContext\b|\$context$)/';
    $positional = 0;

    foreach ($arguments as $argument) {
        if (preg_match('/^('.$refused.'):(?!:)/', $argument, $match)) {
            return 'named argument '.$match[1];
        }
        if (!preg_match('/^[A-Za-z_]+:(?!:)/', $argument)) {
            $positional++;
        }
    }

    if ($positional <= $limit || preg_match($changed, $arguments[$limit])) {
        return null;
    }

    return $positional.' positional arguments';
}

$classes = [];
$calls = [];

foreach (array_slice($argv, 1) as $dir) {
    if (!is_dir($dir)) {
        echo 'Skipped, not a directory: ', $dir, "\n";
        continue;
    }

    $files = new RecursiveIteratorIterator(
        new RecursiveCallbackFilterIterator(
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
            function (SplFileInfo $file): bool {
                $skipped = ['vendor', 'node_modules', '.git'];
                if ($file->isDir()) {
                    return !$file->isLink() &&
                        !in_array($file->getFilename(), $skipped, true);
                }

                return $file->getExtension() === 'php';
            }
        )
    );

    foreach ($files as $file) {
        readSource($file->getPathname(), $classes, $calls);
    }
}

$found = 0;

foreach ($calls as [$class, $arguments, $place]) {
    $constructor = findConstructor($class, $classes);
    $reason = $constructor ? findReason($constructor, $arguments) : null;

    if ($reason !== null) {
        $found++;
        echo $place, '  ', $constructor, ': ', $reason, "\n";
    }
}

echo $found, " calls to change\n";
exit($found ? 1 : 0);

Run it with the directories that hold project code:

php exception-calls.php plugins src tests

It prints one line per call that needs a change and ends with the number of calls. It reads new X(...), new self(...), new static(...) and parent::__construct(...), also when the call is written on several lines, and follows the project classes that extend an exception without declaring a constructor. It skips vendor and node_modules. A copy of the framework that is kept inside the project is reported too; it is replaced by the new version and not edited. A call that unpacks an array (new SystemException(...$arguments)) or creates the class from a variable (new $class(...)) is not found. A call is taken as changed when its third argument (the fourth for FieldException) is written new ExceptionContext(...) or $context, so the script prints 0 calls to change when the work is done.

To list the project exceptions that declare a constructor of their own:

grep -rlE --include='*.php' --exclude-dir=vendor \
  'extends +[A-Za-z_\\]*Exception\b' . \
  | xargs grep -lE 'function +__construct'

Such a class needs a change only when the script reports its parent::__construct() call. Its own parameters can stay as they are.

What to do

Import the class a file uses:

use Festi\Core\Exception\ExceptionContext;

SystemException and the classes that share its constructor: put the third and the fourth argument into new ExceptionContext($data, $source) and pass it as the third argument. The display message moves from the fifth position to the fourth and $previous from the sixth to the fifth.

Before:

throw new SystemException(
    $message,
    $code,
    $data,
    $store,
    $displayMessage,
    $exception
);

After:

throw new SystemException(
    $message,
    $code,
    new ExceptionContext($data, $store),
    $displayMessage,
    $exception
);

A call that passed false, null only to reach the display message names the argument and needs no ExceptionContext. A call that passed false, null, null or false, null, false only to reach $previous does the same with previous.

Before:

throw new SystemException($message, 0, false, null, $displayMessage);

throw new SystemException($message, 0, false, null, null, $exception);

After:

throw new SystemException($message, displayMessage: $displayMessage);

throw new SystemException($message, previous: $exception);

A call with a label, or with an array of data, as the third argument wraps it.

Before:

throw new SystemException($message, 0, 'Something went wrong.');

throw new SystemException($message, 0, $data);

After:

throw new SystemException(
    $message,
    0,
    new ExceptionContext('Something went wrong.')
);

throw new SystemException($message, 0, new ExceptionContext($data));

A call that passed an exception as the third argument stored it as the label and did not chain it. Pass it as previous to chain it, which is what such a call was written for; wrap it in an ExceptionContext to keep it as the label.

Before:

throw new SystemException($message, 0, $exception);

After:

throw new SystemException($message, previous: $exception);

The named arguments data and source become one context argument.

Before:

throw new SystemException(message: $message, data: $label);

After:

throw new SystemException(
    message: $message,
    context: new ExceptionContext($label)
);

A project exception changes the call to its parent by the same rules. Its own parameters do not change, so the code that creates it does not change either.

Before:

class DisplayUserException extends SystemException
{
    public function __construct(string $message = "")
    {
        parent::__construct($message, 0, false, null, $message);
    }
}

class ModuleRequiredException extends SystemException
{
    public function __construct(
        string $message,
        int $code = 0,
        ?Throwable $previous = null
    )
    {
        parent::__construct($message, $code, $previous);
    }
}

After:

class DisplayUserException extends SystemException
{
    public function __construct(string $message = "")
    {
        parent::__construct($message, displayMessage: $message);
    }
}

class ModuleRequiredException extends SystemException
{
    public function __construct(
        string $message,
        int $code = 0,
        ?Throwable $previous = null
    )
    {
        parent::__construct($message, $code, previous: $previous);
    }
}

FieldException: put the fourth and the fifth argument into new ExceptionContext($data, $source) and pass it as the fourth argument; $previous moves from the seventh position to the fifth. The display message leaves the constructor: set it with setDisplayMessage() when it differs from the message.

Before:

throw new FieldException(
    $message,
    '#email',
    0,
    $data,
    $store,
    $displayMessage,
    $exception
);

After:

$fieldException = new FieldException(
    $message,
    '#email',
    0,
    new ExceptionContext($data, $store),
    $exception
);

$fieldException->setDisplayMessage($displayMessage);

throw $fieldException;

With an empty display message getDisplayMessage() returns the message, as it did before.

NotFoundException: no change is needed. Code that read the cause of a NotFoundException from getLabel() reads it from getPrevious().

StoreModel keeps the schema parts in a StoreSchemaParts

CHANGELOG entry: StoreModel keeps the thirteen parts of a schema in a StoreSchemaParts.

What changed

Thirteen protected properties of StoreModel are gone. Their arrays are the parts of one object, which the model holds in a new protected property:

StoreModel: protected Festi\Store\Model\StoreSchemaParts $parts
Before Now, inside a model class
$this->fields $this->parts[StoreSchemaParts::FIELDS]
$this->actions $this->parts[StoreSchemaParts::ACTIONS]
$this->relations $this->parts[StoreSchemaParts::RELATIONS]
$this->grouped $this->parts[StoreSchemaParts::GROUPED]
$this->filters $this->parts[StoreSchemaParts::FILTERS]
$this->sections $this->parts[StoreSchemaParts::SECTIONS]
$this->externalValues $this->parts[StoreSchemaParts::EXTERNAL_VALUES]
$this->routers $this->parts[StoreSchemaParts::ROUTERS]
$this->search $this->parts[StoreSchemaParts::SEARCH]
$this->listeners $this->parts[StoreSchemaParts::LISTENERS]
$this->highlights $this->parts[StoreSchemaParts::HIGHLIGHTS]
$this->aggregations $this->parts[StoreSchemaParts::AGGREGATIONS]
$this->rules $this->parts[StoreSchemaParts::RULES]

The value of each constant is the old property name: 'fields', 'actions', and so on to 'rules'. The new class is in the namespace Festi\Store\Model:

StoreSchemaParts implements ArrayAccess
StoreSchemaParts::__construct()
StoreSchemaParts::offsetExists(mixed $offset): bool
StoreSchemaParts::offsetGet(mixed $offset): array     (returns a reference)
StoreSchemaParts::offsetSet(mixed $offset, mixed $value): void
StoreSchemaParts::offsetUnset(mixed $offset): void
StoreSchemaParts::getNames(): array                   (static)
StoreSchemaParts::isPartName(string $name): bool      (static)

The parts are read and written with array access, by part name, which is what calls the four offset methods:

Code Result
$this->parts[StoreSchemaParts::FIELDS] The part, an array
$this->parts[StoreSchemaParts::FIELDS] = $fields; Replaces the part
$this->parts[StoreSchemaParts::FIELDS][$name] = $field; Changes one item of the part
unset($this->parts[StoreSchemaParts::FIELDS][$name]); Removes one item of the part
$fields = &$this->parts[StoreSchemaParts::FIELDS]; Reference to the part: a write to $fields changes the model
$fields = $this->parts[StoreSchemaParts::FIELDS]; A copy: a write to $fields does not change the model
isset($this->parts[$name]) true for each of the thirteen names, also when the part is empty; false for any other name
$this->parts['columns'], $this->parts['columns'] = []; SystemException: Undefined schema part: columns
$this->parts[] = $part; SystemException: Undefined schema part: null
$this->parts[StoreSchemaParts::FIELDS] = null; TypeError: Schema part "fields" must be of type array, null given
unset($this->parts[StoreSchemaParts::FIELDS]); SystemException: Schema part "fields" cannot be removed: assign an empty array to clear it

A new model holds every part as an empty array, as the properties did.

StoreModel also has the methods __get(), __set(), __isset() and __unset() now. They refuse the thirteen names (see "Who is affected") and keep any other property a model class does not declare where it was kept before: as an item of the model, which is an ArrayObject.

For PHP to call these methods the model no longer answers for properties as an ArrayObject with ARRAY_AS_PROPS does. That changes what the three properties without a default value are. $store, $primaryKey and the private $_name were items of the ArrayObject and are ordinary properties now:

Code Before Now
count($model) after load() 3 0
$model['primaryKey'], outside the model The key null with Warning: Undefined array key "primaryKey"
$model->primaryKey, outside the model The key SystemException: Cannot access property X::$primaryKey
$model->primaryKey = 'id';, outside the model Changed the key of the model The same exception
$this->_name in a class that extends the model The name of the store SystemException: Cannot access property X::$_name
$model->attributes, outside the model null with Warning: Undefined array key "attributes" SystemException: Cannot access property X::$attributes
getName() or getPrimaryKey() before the model has a name or a key TypeError: StoreModel::getName(): Return value must be of type string, null returned Error: Typed property StoreModel::$_name must not be accessed before initialization

Use getPrimaryKey(), getName(), setName() and getAttributes() for these values.

These do not change:

  • Every public method of StoreModel, with its name, its parameters and its result: getFields(), getField(), getFieldByName(), getFieldByOption(), getFieldByElementName(), removeFieldByName(), createFieldInstance(), getActions(), getAction(), getActionByRef(), hasAction(), removeAction(), getGroupActions(), getRelation(), getFilters(), getSections(), getExternalValues(), getRouters(), getSearch(), getHighlights(), getAggregations(), getRules(), getOptions(), getOption(), getAttributes(), get(), set() and the others.
  • The methods that return a reference still return one. Code such as $filters = &$store->getModel()->getFilters(); $filters['status'] = 'active'; changes the model as before.
  • The protected properties $attributes, $options, $primaryKey, $charset, $store and $stream, and every protected method.
  • The model an XML file, an array or a ClassSchema is loaded into: the same parts with the same keys in the same order, and the same exceptions for a schema with a mistake.

One message changes. An array schema without the key actions, or with a part that is not an array, was refused with TypeError: Cannot assign null to property StoreModel::$actions of type array. It is refused with TypeError: Schema part "actions" must be of type array, null given.

Who is affected

A project class that extends StoreModel, XmlStoreModel, ArrayStoreModel or ClassStoreModel and uses one of the thirteen properties, or declares a property with one of these names. Code that only calls the public methods of a model, which is what stores, actions, proxies and plugins do, is not affected.

A model class that still uses one of the names is stopped with an exception. The message is the same on PHP 8.1, 8.2 and 8.3:

Code in the model class Result
$this->actions = []; SystemException: X::$actions is not a property of the store model: the schema part is in $this->parts, read it with $this->parts['actions'] and replace it with $this->parts['actions'] = $part
$this->fields[$name] = $field;, $this->rules[] = $rule; The same exception, with the name of the part
$fields = $this->fields;, foreach ($this->fields as $field) The same exception
isset($this->fields[$name]), empty($this->filters) The same exception
unset($this->fields[$name]);, unset($this->rules); The same exception
protected array $filters = [...]; declared in the class The same exception, when the model is created
$this->parts['columns'] SystemException: Undefined schema part: columns
A constructor that does not call parent::__construct($store), or a model created without its constructor, as a partial mock is Error: Typed property StoreModel::$parts must not be accessed before initialization, at the first use of a part

X is the class of the model. The exception is thrown when the line runs, which for a line in load() is when the store is created.

Without these checks PHP would say nothing, on any version. A model is an ArrayObject that keeps a property its class does not declare as one of its items, so $this->fields[$name] = $field would be kept in an item nothing reads, with no warning and no deprecation notice, and the store would have no fields.

Code outside a model that reads $model->primaryKey, or another protected property, or an item of the model such as $model['primaryKey'], is affected by the second table of "What changed".

Two things change for a property of a model class that is not declared and is not one of the thirteen names. They concern only code that has a mistake already:

  • A read before the first write gave Warning: Undefined array key "name". It gives null without a warning.
  • After such a read, count($model) and a loop over the model include the name, with the value null.

How to find it

Run this in the project root. It prints the lines of every class that extends a store model and uses or declares one of the thirteen names:

grep -rlE --include='*.php' --exclude-dir=vendor \
  'extends +\\?(Xml|Array|Class)?StoreModel([^A-Za-z0-9_]|$)' . \
  | while read -r file; do
      grep -nHE \
        -e '\$this->(fields|actions|relations|grouped|filters|sections|externalValues|routers|search|listeners|highlights|aggregations|rules)([^A-Za-z0-9_(]|$)' \
        -e '(public|protected|private|var) [^;=(]*\$(fields|actions|relations|grouped|filters|sections|externalValues|routers|search|listeners|highlights|aggregations|rules)([^A-Za-z0-9_]|$)' \
        "$file"
    done

No output means no work. The first grep alone lists the model classes of the project. A class that extends a model class of the project and not a framework one is not listed: run the second grep on it by hand. The command does not find a property whose name is in a variable ($this->$name); the exception above reports that one.

In the framework before this change the command prints 18 lines of ArrayStoreModel, 7 of XmlStoreModel and the one line of the test model shown below. In the projects and shared plugins it was run on, no class extends a store model, so it printed nothing.

This one prints the code that reads a property or an item of a model from outside:

grep -rnE --include='*.php' --exclude-dir=vendor \
  -e 'getModel\(\)->[A-Za-z_]+([^A-Za-z_(]|$)' \
  -e 'getModel\(\)\[' \
  -e '[mM]odel->(primaryKey|store|_name|attributes|options|charset|stream)([^A-Za-z_(]|$)' .

The last pattern also matches a class of the project that is called a model and is not a store model. In the framework the command prints only the two lines of the test of this exception, and it printed nothing in the same projects.

What to do

Read a part with $this->parts[$name] and replace it with $this->parts[$name] = $part. To add to a part or to change an item, write to the item: $this->parts[$name][$key] = $value.

Before After
$this->actions = $actions; $this->parts[StoreSchemaParts::ACTIONS] = $actions;
$this->fields[$name] = $field; $this->parts[StoreSchemaParts::FIELDS][$name] = $field;
unset($this->actions[$name]); $this->removeAction($name);
foreach ($this->fields as $field) foreach ($this->getFields() as $field)
isset($this->fields[$name]) $this->getField($name) !== null
protected array $filters = ['status' => 'active']; Remove the declaration and run $this->parts[StoreSchemaParts::FILTERS] = $filters; in load()

The public methods of the model work inside a model class too, and they are the shortest replacement for a read.

Before:

class ProductsStoreModel extends StoreModel
{
    public function load(): void
    {
        $values = [
            'name'       => 'products',
            'primaryKey' => 'id',
        ];

        $this->attributes = $this->getExtendData(
            $values,
            $this->getAttributesOptions(),
            $errors
        );

        $this->setName($this->attributes['name']);
        $this->primaryKey = $this->attributes['primaryKey'];

        $attributes = [
            'type'    => 'text',
            'name'    => 'caption',
            'caption' => 'Caption',
        ];

        $field = $this->createFieldInstance(
            new ArrayFieldModel($attributes),
            0
        );

        $this->fields[$field->getName()] = $field;

        $this->actions = [
            Store::ACTION_LIST => [
                'type'    => Store::ACTION_LIST,
                'caption' => 'List',
            ],
        ];

        $this->doPrepareActions();

        $this->filters['caption'] = 'welcome';
    }
}

After:

use Festi\Store\Model\StoreSchemaParts;

class ProductsStoreModel extends StoreModel
{
    public function load(): void
    {
        $values = [
            'name'       => 'products',
            'primaryKey' => 'id',
        ];

        $this->attributes = $this->getExtendData(
            $values,
            $this->getAttributesOptions(),
            $errors
        );

        $this->setName($this->attributes['name']);
        $this->primaryKey = $this->attributes['primaryKey'];

        $attributes = [
            'type'    => 'text',
            'name'    => 'caption',
            'caption' => 'Caption',
        ];

        $field = $this->createFieldInstance(
            new ArrayFieldModel($attributes),
            0
        );

        $this->parts[StoreSchemaParts::FIELDS][$field->getName()] = $field;

        $actions = [
            Store::ACTION_LIST => [
                'type'    => Store::ACTION_LIST,
                'caption' => 'List',
            ],
        ];

        $this->parts[StoreSchemaParts::ACTIONS] = $actions;
        $this->doPrepareActions();

        $filters = &$this->parts[StoreSchemaParts::FILTERS];
        $filters['caption'] = 'welcome';
    }
}

tests/Resources/LegacyModel/SchemaPartsCustomStoreModel.php is this model in the framework, and tests/Resources/LegacyModel/LegacyCustomStoreModel.php is the model that only had the line $this->actions = [];.

A test that creates a model without its constructor and then reads a part must call the constructor, which creates the parts. A store mock is enough for it:

$store = $this->createMock(Store::class);
$model = new ProductsStoreModel($store);

Store keeps its pagination and its components in two objects

CHANGELOG entry: Store keeps the page and the order of its list in a StorePagination and its model, view, proxy and rules manager in a StoreComponents.

What changed

Nine protected properties of Store are gone. Their values are in two objects, which the store holds in two new protected properties:

Store: protected Festi\Store\StorePagination $pagination
Store: protected Festi\Store\StoreComponents $components
Before Now, inside a store class
$this->rowsPerPage $this->pagination->getRowsPerPage(), setRowsPerPage(int $rowsPerPage)
$this->currentPage $this->pagination->getCurrentPage(), setCurrentPage(?int $currentPage)
$this->totalRows $this->pagination->getTotalRows(), setTotalRows(?int $totalRows)
$this->orderByFieldName $this->pagination->getOrderByFieldName(), setOrderByFieldName(?string $orderByFieldName)
$this->orderByDirection $this->pagination->getOrderByDirection(), setOrderByDirection(?string $orderByDirection)
$this->model $this->components->model
$this->view $this->components->view
$this->proxy $this->components->proxy
$this->rulesManager $this->components->rulesManager

Both classes are in the namespace Festi\Store. A StorePagination has public methods, a StoreComponents has public typed properties:

StorePagination::getRowsPerPage(): int
StorePagination::setRowsPerPage(int $rowsPerPage): void
StorePagination::getCurrentPage(): ?int
StorePagination::setCurrentPage(?int $currentPage): void
StorePagination::getTotalRows(): ?int
StorePagination::setTotalRows(?int $totalRows): void
StorePagination::getOrderByFieldName(): ?string
StorePagination::setOrderByFieldName(?string $orderByFieldName): void
StorePagination::getOrderByDirection(): ?string
StorePagination::setOrderByDirection(?string $orderByDirection): void
StoreComponents: public StoreModel $model
StoreComponents: public StoreView $view
StoreComponents: public IProxy $proxy
StoreComponents: public ?Festi\Store\Rule\IRulesManager $rulesManager = null

A StorePagination keeps what it is given and resolves nothing. A value the store has not resolved yet is 0 for the rows per page and null for the other four, as the properties were; $currentPage had no value at all before the first setCurrentPageIndex() and is null now. The store still resolves each value from the request, the session and the model in getRowsPerPageCount(), getTotalCount(), getCurrentPageIndex(), getOrderByFieldName() and getOrderByDirection(), and keeps the result in the pagination.

The store sets the model, the view and the proxy of its StoreComponents in its constructor, in that order, and the rules manager in onInit(). $this->components->rulesManager is null until then, where isset($this->rulesManager) was false. A read of $model, $view or $proxy of the components before the store has set it is stopped by PHP with an Error: Typed property Festi\Store\StoreComponents::$model must not be accessed before initialization, or Cannot access uninitialized non-nullable property Festi\Store\StoreComponents::$model by reference when the read is getModel() of the store. Each of the four properties accepts only a value of its type: PHP throws a TypeError for any other, also when the value is assigned through a reference.

A new class Festi\Store\StoreComponentsFactory creates the model, the view, the proxy and the rules manager. The private methods of Store that did it are gone; a project class could not call or override them.

Store also has the methods __get(), __set(), __isset(), __unset() and __clone() now. The first four refuse the nine names (see "Who is affected") and leave every other property a store class does not declare where PHP keeps it: in a property of the object. __clone() gives a clone its own StorePagination and its own StoreComponents, so cloneInstance() returns what it returned before: a store that shares the model, the view and the proxy objects with the original and changes its page, or replaces a component, without the original.

These do not change:

  • Every public method of Store, PluginStore and IStore, with its name, its parameters and its result, among them getModel(), getView(), getProxy(), getRowsPerPageCount(), setRowsPerPageCount(), getTotalCount(), setTotalCount(), getCurrentPageIndex(), setCurrentPageIndex(), getOrderByFieldName(), getOrderByDirection() and cloneInstance().
  • getModel(), getView() and getProxy() still return a reference. Code such as $proxy = &$store->getProxy(); $proxy = $other; replaces the proxy of the store as before.
  • The protected properties $ident, $session, $connection, $request, $core and $parentFieldName, and the protected methods onInit() and onPrepareOptions(). $plugin and getRulesManager() change in Store keeps its plugin in its components.
  • A store is an ArrayObject without ARRAY_AS_PROPS, as it was: getFlags() is 0, count($store) is 0, a property is not an item, and $store['key'], a loop over the store and getArrayCopy() see only the items written with $store['key'] = ....

Who is affected

A project class that extends Store, PluginStore or a store class of the project and uses one of the nine properties, or declares a property with one of these names. Code that only calls the public methods of a store, which is what plugins, actions, proxies and listeners do, is not affected.

A store class that still uses one of the names is stopped with an exception. The message is the same on PHP 8.1, 8.2 and 8.3:

Code in the store class Result
$this->orderByFieldName = 'name'; SystemException: X::$orderByFieldName is not a property of the store: the value is in $this->pagination, read it with $this->pagination->getOrderByFieldName() and set it with $this->pagination->setOrderByFieldName($value)
$this->proxy->loadForeignKeyValues($field); SystemException: X::$proxy is not a property of the store: the value is in $this->components, read it and set it as $this->components->proxy
$total = $this->totalRows;, $this->currentPage++;, $this->model->getFields() The exception of the row above that names the object of the property: the first for the page and the order, the second for a component
isset($this->rulesManager), empty($this->view) The same exception
unset($this->rowsPerPage); The same exception
protected int $rowsPerPage = 50; declared in the class The same exception, when the store is created
$store->model, $store->rowsPerPage = 5;, isset($store->proxy), outside the store The same exception. Before it was Error: Cannot access protected property X::$model, and false for isset()
A constructor that uses $this->pagination or $this->components before parent::__construct(), or a store created without its constructor, as a partial mock is Error: Typed property Store::$pagination must not be accessed before initialization (or $components), at the first use

X is the class of the store. The exception is thrown when the line runs.

Without these checks PHP would not stop such a class. Measured with the same class and the checks removed:

Code in the store class PHP 8.1 PHP 8.2 and 8.3
$this->orderByFieldName = 'name'; Nothing. The value is kept in a new property nothing reads, and the list keeps its order The same, with Deprecated: Creation of dynamic property X::$orderByFieldName is deprecated
$total = $this->totalRows; null with Warning: Undefined property: X::$totalRows The same
$this->proxy->loadForeignKeyValues($field); The same warning and Error: Call to a member function loadForeignKeyValues() on null The same
isset($this->rulesManager), empty($this->view) false and true, no message The same
protected int $rowsPerPage = 50; declared in the class Nothing. The store is created and never reads the property The same

The four methods also change what PHP does for other properties of a store. Each of these concerns code that has a mistake already or that is unusual. The third and the fourth were searched for in the store classes of the projects the command below was run on, and not found; a search cannot find the first:

Code Before Now
A read of a property that is not declared and was never written, such as $this->cahce null with Warning: Undefined property: X::$cahce null. PHP 8.1 gives no message; PHP 8.2 and 8.3 give Deprecated: Creation of dynamic property X::$cahce is deprecated. The object has the property afterwards, with the value null
$store->ident, $store->ident = 'x';, unset($store->ident);, outside the store; the same for any other protected or private property Error: Cannot access protected property X::$ident Error: Cannot access property X::$ident
$this->_name or $this->_options in a class that extends Store, which are private properties of Store and its traits A read gave null with Warning: Undefined property; a write created a second property of that name on the object Error: Cannot access property X::$_name
unset($this->cache); on a property the class declares, and then $this->cache = $value; or a read of it The write set the property again; the read gave null with a warning Error: Cannot access property X::$cache
A store created without its constructor, then setRowsPerPageCount() Set the value Error: Typed property Store::$pagination must not be accessed before initialization

A write to a property that is not declared, $this->list[] = $value on such a property, isset() and unset() of it behave as before, with the same Deprecated: Creation of dynamic property message on PHP 8.2 and 8.3 for a class without #[AllowDynamicProperties].

How to find it

Run this in the project root. It prints the lines of every class that extends a store and uses or declares one of the nine names:

grep -rlE --include='*.php' --exclude-dir=vendor \
  'extends +[A-Za-z0-9_\\]*Store[A-Za-z0-9_]*' . \
  | while read -r file; do
      grep -nHE \
        -e '\$this->(rowsPerPage|currentPage|totalRows|orderByFieldName|orderByDirection|model|view|proxy|rulesManager)([^A-Za-z0-9_(]|$)' \
        -e '(public|protected|private|var) [^;=(]*\$(rowsPerPage|currentPage|totalRows|orderByFieldName|orderByDirection|model|view|proxy|rulesManager)([^A-Za-z0-9_]|$)' \
        "$file"
    done

No output means no work. The first grep takes every class whose parent has Store in its name, so that a class that extends a store class of the project is found too. It also takes a class that extends StoreModel, StoreView or StoreProxy; such a class has a $model or a $view of its own and is not affected. A store class whose parent has no Store in its name is not listed: run the second grep on it by hand. The command does not find a property whose name is in a variable ($this->$name); the exception above reports that one.

In the framework the command prints the line of the test store that declares $proxy and twelve lines of SqlProxy, which is a proxy and uses its own $model. In the fourteen projects and shared plugins it was run on, its first grep lists 676 files and the command prints three lines in two classes: one class that sets $this->orderByFieldName and $this->orderByDirection, and one that calls $this->proxy->loadForeignKeyValues().

What to do

For the model, the view and the proxy use the public methods of the store, which are the shortest replacement for a read. For the page and the order use $this->pagination.

Before After
$this->model->getFields() $this->getModel()->getFields()
$this->proxy->loadForeignKeyValues($field) $this->getProxy()->loadForeignKeyValues($field)
$this->view->fetch($template) $this->getView()->fetch($template)
$this->proxy = $proxy; $this->components->proxy = $proxy;
isset($this->rulesManager) $this->components->rulesManager !== null
$this->rulesManager->createRule($type) $this->components->rulesManager->createRule($type)
$this->orderByFieldName = $expression; $this->pagination->setOrderByFieldName($expression);
$this->orderByDirection = 'ASC'; $this->pagination->setOrderByDirection('ASC');
$this->rowsPerPage = 50; $this->setRowsPerPageCount(50);
$this->totalRows = $total; $this->pagination->setTotalRows($total);, or $this->setTotalCount($total); to save it in the session too
$this->totalRows $this->pagination->getTotalRows() for the kept value, $this->getTotalCount() to resolve it
protected int $rowsPerPage = 50; Remove the declaration and call $this->pagination->setRowsPerPage(50); in onInit()

setOrderByFieldName() and setOrderByDirection() are the only way to give a store an order that is not a field of its model, as a write to the two properties was: getOrderByFieldName() returns the value as it is and does not check it.

Before:

class CandidatesStore extends Store
{
    protected int $rowsPerPage = 50;

    public function orderByRelevance(array $ids): void
    {
        $this->orderByFieldName = 'FIELD(id, '.implode(', ', $ids).')';
        $this->orderByDirection = 'ASC';
        $this->totalRows = count($ids);
    }

    public function loadGrades(IStoreForeignField $field): bool
    {
        if (!$this->model->getFieldByName($field->getName())) {
            return false;
        }

        return $this->proxy->loadForeignKeyValues($field);
    }

    public function isFirstPage(): bool
    {
        return !isset($this->currentPage) || $this->currentPage === 1;
    }
}

After:

class CandidatesStore extends Store
{
    protected function onInit(): void
    {
        parent::onInit();

        $this->pagination->setRowsPerPage(50);
    }

    public function orderByRelevance(array $ids): void
    {
        $this->pagination->setOrderByFieldName(
            'FIELD(id, '.implode(', ', $ids).')'
        );
        $this->pagination->setOrderByDirection('ASC');
        $this->pagination->setTotalRows(count($ids));
    }

    public function loadGrades(IStoreForeignField $field): bool
    {
        if (!$this->getModel()->getFieldByName($field->getName())) {
            return false;
        }

        return $this->getProxy()->loadForeignKeyValues($field);
    }

    public function isFirstPage(): bool
    {
        $page = $this->pagination->getCurrentPage();

        return $page === null || $page === 1;
    }
}

tests/Resources/store/RelevanceOrderStore.php is a store of this kind in the framework, and tests/Resources/store/LegacyPropertyStore.php is the one every method of which is stopped.

A test that creates a store without its constructor and then calls a method that uses the page, the order, the model, the view or the proxy must create the store with its constructor. A connection mock that names its database type is enough for a store with an XML schema:

$connection = $this->createMock(IDataAccessObject::class);
$connection->method('getDatabaseType')->willReturn('mysql');

$options = [];
$store = new CandidatesStore($connection, 'candidates', $options);

AbstractAction::getStore() returns Store

CHANGELOG entry: AbstractAction builds its url through an ActionUrlBuilder and handles an exception through an ActionExceptionHandler.

What changed

AbstractAction::getStore() declares the class of the store it returns, which is the class of the store an action is created with:

before: public function &getStore(): IStore
now:    public function &getStore(): Store

IStoreAction::getStore() still declares IStore. getUrl(), doHandleException() and every other method of AbstractAction keep their signatures and do what they did.

Who is affected

A project class that extends AbstractAction, or an action that extends it (InsertAction, EditAction, RemoveAction, InfoAction, ListAction, PluginAction and the others), and overrides getStore() with the return type IStore. PHP refuses to load such a class:

Fatal error: Declaration of & ReportAction::getStore(): core\store\IStore
must be compatible with & AbstractAction::getStore(): Store

Code that calls getStore() is not affected: a Store is an IStore. An action class that does not override getStore() is not affected. A class that implements IStoreAction without extending AbstractAction, and a rule or a view with a getStore() of its own, is not affected.

How to find it

The class-load check of How to upgrade a project reports every such class. This command lists every getStore() that declares IStore; only those in a class that extends an action have to change:

grep -rnE --include='*.php' --exclude-dir=vendor \
  'function +&? *getStore\(\) *: *\\?(core\\store\\)?IStore' .

What to do

Declare Store as the return type, or remove the override when it only returns $this->store.

Before:

<?php

use core\store\IStore;

class ReportAction extends AbstractAction
{
    public function getActionName(): string
    {
        return 'report';
    }

    public function &getStore(): IStore
    {
        return $this->store;
    }
}

After:

<?php

class ReportAction extends AbstractAction
{
    public function getActionName(): string
    {
        return 'report';
    }

    public function &getStore(): Store
    {
        return $this->store;
    }
}

Store keeps its plugin in its components

CHANGELOG entry: Store keeps its plugin in its StoreComponents, and StoreComponentsFactory creates its plugin, its rules manager and its audit.

What changed

One protected property and one protected method of Store are gone:

before: Store: protected ?AbstractPlugin $plugin
now:    StoreComponents: public ?AbstractPlugin $plugin = null

before: protected function getRulesManager(): IRulesManager
now:    no method; the rules manager is $this->components->rulesManager
Before, inside a store class Now
$this->plugin (read) $this->components->plugin, or $this->getPlugin()
$this->plugin = $plugin; $this->components->plugin = $plugin;
isset($this->plugin) $this->components->plugin !== null
$this->getRulesManager() $this->components->rulesManager
protected function getRulesManager(): IRulesManager that returns a rules manager of the project onInit() that sets $this->components->rulesManager before parent::onInit()

$this->components is the Festi\Store\StoreComponents of the section above. It has two more public typed properties:

StoreComponents: public ?AbstractPlugin $plugin = null
StoreComponents: public ?core\store\StoreAudit $audit = null

$plugin is the plugin the plugin attribute of the schema names, or the one given to setPlugin(); PluginStore gives it the plugin it was created with. $audit is what records the changes of a store whose schema has auditMode; null for any other store. Both accept only a value of their type: PHP throws a TypeError for any other.

Festi\Store\StoreComponentsFactory creates the three:

public function createPlugin(Store $store): ?AbstractPlugin
public function createRulesManager(Store $store): IRulesManager
public function createAudit(Store $store): ?StoreAudit

createRulesManager() had no parameter; it initialises the rules manager with the store now, which Store did. No release had the method.

The store sets $this->components->plugin in its constructor, after the model, the view and the proxy. onInit() then sets $this->components->rulesManager, unless the components keep one already, and $this->components->audit.

Three public methods are new, and Store uses them:

Festi\Store\StoreIdentResolver::isSchemaClass(string $reference): bool
StoreModel::getErrorMessage(): mixed
StoreModel::getPermissionSection(): mixed

The last two return the errorMessage and the permission attribute of the table.

These do not change:

  • Every public method of Store, PluginStore and IStore, with its name, its parameters and its result. getPlugin() still returns a reference, so $plugin = &$store->getPlugin(); $plugin = $other; replaces the plugin of the store, and setPlugin() still keeps a reference to the variable it is given.
  • The constructor of Store and of PluginStore.
  • The protected properties $ident, $session, $connection, $request, $core, $parentFieldName, $pagination and $components, and the protected methods onInit() and onPrepareOptions().
  • A clone shares the plugin with the store it was made from, and setPlugin() on the clone leaves the store its own.
  • The plugin a store has, the listeners of an audited store, the exception a user without the permission gets and its message.

One error text changes. A schema built from an array or a class whose plugin attribute is an object that is not a plugin was refused with TypeError: Store::_getPluginInstance(): Return value must be of type ?AbstractPlugin, stdClass returned. The TypeError now names Festi\Store\StoreComponentsFactory::createPlugin().

Who is affected

A project class that extends Store, PluginStore or a store class of the project and uses $this->plugin, declares a property $plugin, or calls or declares getRulesManager(). Code that calls getPlugin() or setPlugin() is not affected. A plugin class is not affected: $this->plugin of an AbstractPlugin is another property.

A store class that still uses one of them is stopped. The message is the same on PHP 8.1, 8.2 and 8.3:

Code in the store class Result
$this->plugin->getByID($id), $plugin = $this->plugin;, $this->plugin instanceof InvoicesPlugin SystemException: X::$plugin is not a property of the store: the value is in $this->components, read it and set it as $this->components->plugin
$this->plugin = $plugin;, $this->plugin = &$plugin; The same exception
isset($this->plugin), empty($this->plugin), unset($this->plugin); The same exception
protected ?InvoicesPlugin $plugin = null; declared in the class, with any type or none The same exception, when the store is created
$store->plugin, outside the store The same exception. Before it was Error: Cannot access protected property X::$plugin
$this->getRulesManager() Error: Call to undefined method X::getRulesManager()
protected function getRulesManager(): IRulesManager declared in the class, with any visibility SystemException: X::getRulesManager() is not a method of the store: the rules manager is in $this->components, read it and set it as $this->components->rulesManager, when the store is created
$this->plugin = $plugin; before parent::__construct() The same exception. Written as $this->components->plugin = $plugin; in that place it is Error: Attempt to assign property "plugin" on null
getPlugin() or setPlugin() of a store created without its constructor, as a partial mock is Error: Attempt to modify property "plugin" on null. Before, getPlugin() returned null and setPlugin() set the plugin
(new StoreComponentsFactory())->createRulesManager() ArgumentCountError: Too few arguments to function Festi\Store\StoreComponentsFactory::createRulesManager(), 0 passed in <file> on line <n> and exactly 1 expected

X is the class of the store. The exception is thrown when the line runs.

Without these checks PHP would not stop such a class. Measured with the same class and the checks removed:

Code in the store class PHP 8.1 PHP 8.2 and 8.3
$this->plugin->getByID($id) Warning: Undefined property: X::$plugin and Error: Call to a member function getByID() on null The same
$this->plugin instanceof InvoicesPlugin false with the same warning The same
$this->plugin = $plugin; Nothing. The value is kept in a new property; getPlugin() and the framework keep the plugin of the components The same, with Deprecated: Creation of dynamic property X::$plugin is deprecated
protected ?InvoicesPlugin $plugin = null; declared in the class Nothing. The store is created and never sets the property The same
protected function getRulesManager(): IRulesManager declared in the class Nothing. The store is created and never calls the method The same

How to find it

Save this as store-plugin-property.php in the project root:

<?php
// Usage: php store-plugin-property.php [--write] [--store=Name] <dir> ...
// Lists, in every class that extends Store, PluginStore or a store class
// found in the directories, each `$this->plugin` ([use], or [write] when it
// is assigned) and what else the section "Store keeps its plugin in its
// components" names. With --write it rewrites `$this->plugin` to
// `$this->components->plugin` in those classes and lists the rest, which is
// changed by hand. --store=Name adds a store class whose file is not in
// the directories, such as one of a package.

const SKIPPED_DIRECTORIES = ['vendor', 'node_modules', '.git'];
const TYPE_NAMES = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED];
const BLANK = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
const ARROWS = [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR];
const BRACES = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES];
const VISIBILITY = [T_PUBLIC, T_PROTECTED, T_PRIVATE, T_VAR];
const REWRITTEN_KINDS = ['use', 'write'];

function shortName(string $name): string
{
    return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}

function isToken(mixed $token, int ...$kinds): bool
{
    return is_array($token) && in_array($token[0], $kinds, true);
}

function nextIndex(array $tokens, int $index): int
{
    do {
        $index++;
    } while (isset($tokens[$index]) && isToken($tokens[$index], ...BLANK));

    return $index;
}

function previousIndex(array $tokens, int $index): int
{
    do {
        $index--;
    } while ($index >= 0 && isToken($tokens[$index], ...BLANK));

    return $index;
}

function findFiles(string $directory): array
{
    $files = [];
    $entries = scandir($directory) ?: [];

    foreach (array_diff($entries, ['.', '..']) as $entry) {
        $path = rtrim($directory, '/').'/'.$entry;
        if (is_link($path)) {
            continue;
        }
        if (is_dir($path) && !in_array($entry, SKIPPED_DIRECTORIES, true)) {
            $files = array_merge($files, findFiles($path));
        } else if (is_file($path) && str_ends_with($entry, '.php')) {
            $files[] = $path;
        }
    }

    return $files;
}

// Returns the classes of a file: name, parent and the token range of the
// body, with the bodies of the classes declared inside it left out.
function readClasses(array $tokens): array
{
    $classes = [];
    $open = [];
    $depth = 0;
    $pending = null;

    foreach ($tokens as $index => $token) {
        if (isToken($token, T_CLASS)) {
            $before = $tokens[previousIndex($tokens, $index)] ?? null;
            if (isToken($before, T_DOUBLE_COLON)) {
                continue;
            }
            $name = $tokens[nextIndex($tokens, $index)] ?? null;
            $pending = [
                'name'   => isToken($name, T_STRING) ? $name[1] : null,
                'parent' => null,
                'holes'  => [],
            ];
        } else if ($pending && !$pending['parent'] &&
            isToken($token, T_EXTENDS)) {
            $parent = $tokens[nextIndex($tokens, $index)];
            $pending['parent'] = shortName($parent[1]);
        } else if ($token === '{' || isToken($token, ...BRACES)) {
            $depth++;
            if ($pending && $token === '{') {
                $open[$depth] = $pending + ['from' => $index];
                $pending = null;
            }
        } else if ($token === '}') {
            if (isset($open[$depth])) {
                $class = $open[$depth] + ['to' => $index];
                unset($open[$depth]);
                $outer = array_key_last($open);
                if ($outer !== null) {
                    $open[$outer]['holes'][] = [$class['from'], $index];
                }
                $classes[] = $class;
            }
            $depth--;
        }
    }

    return $classes;
}

function isInHole(array $class, int $index): bool
{
    foreach ($class['holes'] as [$from, $to]) {
        if ($index > $from && $index < $to) {
            return true;
        }
    }

    return false;
}

// Returns what a store class has to change: [token index, line, kind].
function readFindings(array $tokens, array $class): array
{
    $findings = [];
    $inString = false;
    $braces = 0;

    for ($index = $class['from']; $index < $class['to']; $index++) {
        $token = $tokens[$index];
        if ($token === '"' || isToken($token, T_START_HEREDOC, T_END_HEREDOC)) {
            $inString = !$inString;
            $braces = 0;
        } else if ($inString && isToken($token, ...BRACES)) {
            $braces++;
        } else if ($inString && $token === '}') {
            $braces--;
        }
        if (isInHole($class, $index) || !is_array($token)) {
            continue;
        }

        $next = $tokens[nextIndex($tokens, $index)] ?? null;
        $before = $tokens[previousIndex($tokens, $index)] ?? null;
        $kind = null;

        if (isToken($token, T_STRING) && $token[1] === 'plugin' &&
            isToken($before, ...ARROWS) && $next !== '(') {
            $owner = $tokens[previousIndex(
                $tokens,
                previousIndex($tokens, $index)
            )];
            if (isToken($owner, T_VARIABLE) && $owner[1] === '$this') {
                $kind = $next === '=' ? 'write' : 'use';
                $kind = $inString && $braces === 0 ? 'in a string' : $kind;
            }
        } else if (isToken($token, T_VARIABLE) && $token[1] === '$plugin') {
            $start = $index;
            do {
                $start = previousIndex($tokens, $start);
                $part = $tokens[$start] ?? null;
            } while (isToken($part, T_STATIC, T_READONLY, ...TYPE_NAMES) ||
                in_array($part, ['?', '|'], true));
            $kind = isToken($part, ...VISIBILITY) ? 'declaration' : null;
        } else if (isToken($token, T_STRING) &&
            strtolower($token[1]) === 'getrulesmanager') {
            $kind = 'getRulesManager';
        }

        if ($kind !== null) {
            $findings[] = [$index, $token[2], $kind];
        }
    }

    return $findings;
}

// Returns the tokens of a file and its classes that extend another class;
// null when the file has none or PHP cannot parse it.
function readSource(string $path): ?array
{
    $source = file_get_contents($path);
    if (stripos($source, 'extends') === false) {
        return null;
    }

    try {
        $tokens = token_get_all($source, TOKEN_PARSE);
    } catch (ParseError $error) {
        fwrite(STDERR, $path.': not parsed, '.$error->getMessage()."\n");

        return null;
    }

    $classes = array_filter(
        readClasses($tokens),
        fn (array $class): bool => $class['parent'] !== null
    );

    return $classes ? [$tokens, array_values($classes), $source] : null;
}

error_reporting(E_ALL & ~E_DEPRECATED);

$arguments = array_slice($argv, 1);
$isWrite = in_array('--write', $arguments, true);
$stores = ['Store' => true, 'PluginStore' => true];
$files = [];

foreach ($arguments as $argument) {
    if (str_starts_with($argument, '--store=')) {
        $stores[shortName(substr($argument, 8))] = true;
    } else if ($argument !== '--write') {
        $files = array_merge($files, findFiles($argument));
    }
}

$parents = [];
foreach ($files as $path) {
    foreach (readSource($path)[1] ?? [] as $class) {
        $parents[$path][] = [$class['name'], $class['parent']];
    }
}

do {
    $count = count($stores);
    foreach ($parents as $pairs) {
        foreach ($pairs as [$name, $parent]) {
            if ($name !== null && isset($stores[$parent])) {
                $stores[$name] = true;
            }
        }
    }
} while (count($stores) > $count);

$total = ['use' => 0, 'by hand' => 0, 'files' => 0];
foreach ($parents as $path => $pairs) {
    $isStoreFile = false;
    foreach ($pairs as [$name, $parent]) {
        $isStoreFile = $isStoreFile || isset($stores[$parent]);
    }
    if (!$isStoreFile) {
        continue;
    }

    [$tokens, $classes, $source] = readSource($path);
    $lines = explode("\n", $source);
    $isChanged = false;

    foreach ($classes as $class) {
        if (!isset($stores[$class['parent']])) {
            continue;
        }
        foreach (readFindings($tokens, $class) as [$index, $line, $kind]) {
            $isUse = in_array($kind, REWRITTEN_KINDS, true);
            if ($isWrite && $isUse) {
                $tokens[$index][1] = 'components->plugin';
                $isChanged = true;
                $kind .= ', rewritten';
            }
            $total[$isUse ? 'use' : 'by hand']++;
            printf(
                "%s:%d: [%s] %s\n",
                $path,
                $line,
                $kind,
                trim($lines[$line - 1])
            );
        }
    }

    if ($isChanged) {
        $parts = array_map(
            fn (mixed $token): string => is_array($token) ? $token[1] : $token,
            $tokens
        );
        file_put_contents($path, implode('', $parts));
        $total['files']++;
    }
}

printf(
    "%d store classes; \$this->plugin: %d %s; to change by hand: %d\n",
    count($stores) - 2,
    $total['use'],
    $isWrite ? 'rewritten in '.$total['files'].' files' : 'found',
    $total['by hand']
);

Run it in the project root on the directories that hold the code of the project. Without --write it changes nothing:

php store-plugin-property.php .

It prints one line for each place and a total:

./plugins/Invoices/domain/store/InvoicesStore.php:104: [use] $invoiceValuesObject = $this->plugin->getByID($event->getPrimaryKeyValue());
./plugins/Redirects/RedirectsStore.php:44: [write] $this->plugin = $plugin;
./plugins/Reports/ReportsStore.php:12: [declaration] protected ?ReportsPlugin $plugin = null;
./plugins/Reports/ReportsStore.php:40: [in a string] $title = "Report of $this->plugin";
./plugins/Reports/ReportsStore.php:51: [getRulesManager] $rule = $this->getRulesManager()->createRule($type);
62 store classes; $this->plugin: 8 found; to change by hand: 3

0 found; to change by hand: 0 means no work. The script reads the extends of every class in the directories and follows it up to Store and PluginStore, so it lists a class that extends a store class of the project and leaves out a class that only has Store in its name: a wrapper that implements IStore without extending Store, a widget or a proxy has a $plugin of its own, which is not affected. It does not look into vendor, node_modules and .git. It does not see:

  • A store class whose parent is in a package: name the parent with --store=Name, as in php store-plugin-property.php --store=PluginStoreWrapper ..
  • A trait that a store class uses.
  • A property whose name is in a variable ($this->$name) or in a string ($this->{'plugin'}).

The exceptions above report these when the line runs.

In the framework the script prints the two test stores that declare $plugin and getRulesManager(). In the projects and shared plugins it was run on it finds 23 lines in 13 classes of two projects, one of them a [write], and nothing to change by hand: no class declares $plugin and none calls or declares getRulesManager().

A grep for \$this->plugin in the files of classes whose parent has Store in its name lists more than is affected: in one project 72 lines in 18 files, of which 13 lines in 10 files are in a store class. The other lines belong to wrappers and widgets, which must stay as they are.

What to do

  1. Run the script with --write. It replaces $this->plugin with $this->components->plugin in every line it lists as [use] or [write], and changes nothing else in the file:
php store-plugin-property.php --write .

The replacement is right for a read, a write, isset(), a reference and {$this->plugin->name} in a string. Review the result with git diff; run the script again and it prints only what is left.

  1. Look at each [write] line. A constructor that sets the plugin before parent::__construct() is stopped now with Error: Attempt to assign property "plugin" on null: the store creates its components in its constructor. Such a line had one effect: the view of a store reads getPlugin() when it is created and adds getPluginTemplatePath() of the plugin to the folders it looks for templates in, and at that moment the store has only a plugin that was set this early. To keep the effect, keep the plugin in a property of the class and set $this->components->plugin in onPrepareOptions(), which the store calls after it has created its components and before it creates its model, its view and its proxy; see the second example below. Remove the line instead when the plugin has no getPluginTemplatePath() of its own, or the store shows no template of the plugin: PluginStore makes the plugin it is created with the plugin of the store after parent::__construct().

  2. Change by hand what the script lists with another label:

Label Before After
[declaration] protected ?InvoicesPlugin $plugin = null; Remove the declaration. For the type, add a method such as getInvoicesPlugin(): InvoicesPlugin that returns $this->components->plugin after an instanceof check
[in a string] "Report of $this->plugin" "Report of {$this->components->plugin}"
[getRulesManager], a call $this->getRulesManager()->createRule($type) $this->components->rulesManager->createRule($type)
[getRulesManager], a declaration protected function getRulesManager(): IRulesManager See the third example below

$this->components->rulesManager is null until Store::onInit() has run, and in a store class whose onInit() does not call parent::onInit(). getRulesManager() created the rules manager at the first call instead; nothing but Store::onInit() called it.

Before:

<?php

use core\store\PluginStore;

class InvoicesStore extends PluginStore
{
    public function __construct(InvoicesPlugin $plugin, array $options = [])
    {
        parent::__construct('invoices', $plugin, $options);
    }

    public function onUpdate(StoreActionEvent &$event): void
    {
        $invoice = $this->plugin->getByID($event->getPrimaryKeyValue());

        if ($invoice && isset($this->plugin)) {
            $this->plugin->generateDocumentsByID($invoice->getID());
        }
    }

    public function getDateFormat(): string
    {
        return $this->plugin->getSetting('db_datetime_format');
    }
}

After:

<?php

use core\store\PluginStore;

class InvoicesStore extends PluginStore
{
    public function __construct(InvoicesPlugin $plugin, array $options = [])
    {
        parent::__construct('invoices', $plugin, $options);
    }

    public function onUpdate(StoreActionEvent &$event): void
    {
        $plugin = $this->components->plugin;
        $invoice = $plugin->getByID($event->getPrimaryKeyValue());

        if ($invoice && $this->components->plugin !== null) {
            $plugin->generateDocumentsByID($invoice->getID());
        }
    }

    public function getDateFormat(): string
    {
        return $this->getPlugin()->getSetting('db_datetime_format');
    }
}

A store whose view shows the templates of its plugin, before:

<?php

use core\store\PluginStore;

class RedirectsStore extends PluginStore
{
    public function __construct(RedirectsPlugin $plugin, array $options = [])
    {
        $this->plugin = $plugin;

        parent::__construct('redirects', $plugin, $options);
    }
}

After:

<?php

use core\store\PluginStore;

class RedirectsStore extends PluginStore
{
    private RedirectsPlugin $_facade;

    public function __construct(RedirectsPlugin $plugin, array $options = [])
    {
        $this->_facade = $plugin;

        parent::__construct('redirects', $plugin, $options);
    }

    protected function onPrepareOptions(array &$options): void
    {
        parent::onPrepareOptions($options);

        $this->components->plugin = $this->_facade;
    }
}

A store that applies its rules with a rules manager of the project, before:

<?php

use Festi\Store\Rule\IRulesManager;

class ContractsStore extends Store
{
    private ?IRulesManager $_contractRules = null;

    protected function getRulesManager(): IRulesManager
    {
        if ($this->_contractRules === null) {
            $this->_contractRules = new ContractRulesManager();
            $this->_contractRules->onInit($this);
        }

        return $this->_contractRules;
    }
}

After:

<?php

class ContractsStore extends Store
{
    protected function onInit(): void
    {
        $rulesManager = new ContractRulesManager();
        $rulesManager->onInit($this);

        $this->components->rulesManager = $rulesManager;

        parent::onInit();
    }
}

tests/Resources/store/PluginFacadeStore.php, tests/Resources/store/EarlyPluginStore.php and tests/Resources/store/OwnRulesManagerStore.php are stores of these three kinds in the framework.

ClassSchema declares five sections in bindings()

CHANGELOG entry: ClassSchema declares the relations, listeners, routers, rules and external values of a schema in one method, bindings().

What changed

Five protected section methods of Festi\Store\Schema\ClassSchema are gone and one is new:

before: protected function relations(IRelationsBuilder $relations): IRelationsBuilder
before: protected function listeners(IListenersBuilder $listeners): IListenersBuilder
before: protected function routers(IRoutersBuilder $routers): IRoutersBuilder
before: protected function rules(IRulesBuilder $rules): IRulesBuilder
before: protected function externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder
now:    protected function bindings(ISchemaBindings $bindings): ISchemaBindings

bindings() follows the contract of the other section methods: it takes an object, fills it and returns it, and ClassSchema has a default that adds nothing. $bindings is a Festi\Store\Schema\Builder\ISchemaBindings. It has one method for each of the five sections, which returns the builder the removed section method received, the same builder on every call:

public function relations(): IRelationsBuilder
public function listeners(): IListenersBuilder
public function routers(): IRoutersBuilder
public function rules(): IRulesBuilder
public function externalValues(): IExternalValuesBuilder

All fourteen sections of a schema:

Key in the built array Before, in a schema class Now
table table(ITableBuilder $table): ITableBuilder The same
fields fields(IFieldsBuilder $fields): IFieldsBuilder The same
actions actions(IActionsBuilder $actions): IActionsBuilder The same
grouped groupActions(IGroupActionsBuilder $groupActions): IGroupActionsBuilder The same
filters filters(IFiltersBuilder $filters): IFiltersBuilder The same
search search(ISearchBuilder $search): ISearchBuilder The same
aggregations aggregations(IAggregationsBuilder $aggregations): IAggregationsBuilder The same
sections sections(ISectionsBuilder $sections): ISectionsBuilder The same
highlights highlights(IHighlightsBuilder $highlights): IHighlightsBuilder The same
relations relations(IRelationsBuilder $relations): IRelationsBuilder $bindings->relations() in bindings()
listeners listeners(IListenersBuilder $listeners): IListenersBuilder $bindings->listeners() in bindings()
routers routers(IRoutersBuilder $routers): IRoutersBuilder $bindings->routers() in bindings()
rules rules(IRulesBuilder $rules): IRulesBuilder $bindings->rules() in bindings()
externalValues externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder $bindings->externalValues() in bindings()
Before, inside a section method Now, inside bindings()
$relations->add()->child()->field('id'); $bindings->relations()->add()->child()->field('id');
$listeners->add(Store::EVENT_INSERT)->plugin('Users')->method('onInsert'); $bindings->listeners()->add(Store::EVENT_INSERT)->plugin('Users')->method('onInsert');
$routers->add('users')->joinStore('profiles'); $bindings->routers()->add('users')->joinStore('profiles');
$rules->add()->attribute('field', 'email'); $bindings->rules()->add()->attribute('field', 'email');
return $values->add('id_company', $id); $bindings->externalValues()->add('id_company', $id);
return $listeners;, return $values; at the end return $bindings;
parent::listeners($listeners); in a schema that extends a schema of the project parent::bindings($bindings); once, for all five sections

Two classes of Festi\Store\Schema\Builder are new. SchemaBindings implements ISchemaBindings and keeps the five builders. SchemaBuilders creates the builders ClassSchema::build() gives to the section methods:

public function createTable(): TableBuilder
public function createFields(): FieldsBuilder
public function createActions(): ActionsBuilder
public function createGroupActions(): GroupActionsBuilder
public function createFilters(): FiltersBuilder
public function createSearch(): SearchBuilder
public function createAggregations(): AggregationsBuilder
public function createSections(): SectionsBuilder
public function createHighlights(): HighlightsBuilder
public function createBindings(): SchemaBindings

ISchemaBindings::SECTIONS lists the names of the five sections.

build() calls the nine section methods in the order of the first table and bindings() after them. Before, it called relations() after groupActions(), listeners() after search() and routers() after sections(); rules() and externalValues() were the last two then too. Only a schema whose section methods change a property that a later one reads can notice the order.

The five names are reserved in a schema class. build() refuses a schema that has a method named relations, listeners, routers, rules or externalValues, with any visibility and any parameters, its own, inherited or from a trait: nothing calls such a method, so its section would stay empty.

These do not change:

  • The array build() returns, for every schema: the same fourteen keys in the same order with the same values.
  • The nine section methods of the first table, and return parent::fields($fields)->... in a subclass.
  • The fourteen builder interfaces and their classes. A helper method or a trait of the project that takes an IFieldsBuilder, an IListenersBuilder or another builder keeps working: $this->addAuditListeners($bindings->listeners()).
  • IStoreModelSchema, IStoreSchemaProvider and a class that implements IStoreModelSchema without extending ClassSchema.
  • XML and array schemas.

Who is affected

A project class that extends ClassSchema, or a schema class of the project, and declares relations(), listeners(), routers(), rules() or externalValues(). A schema that declares only the other nine section methods is not affected.

PHP loads such a class without an error: the method no longer overrides anything. It is stopped when the schema is built, which is when the store that uses it is created. The message is the same on PHP 8.1, 8.2 and 8.3:

Code in the schema class Result of build()
protected function listeners(IListenersBuilder $listeners): IListenersBuilder (also relations, routers, rules, externalValues) SystemException: X::listeners() is not a section method of the schema: declare the section in bindings(ISchemaBindings $bindings), with $bindings->listeners()
The same method declared public or private, with other parameters, or in a trait the class uses The same exception
No such method in the class, but one in a schema class it extends The same exception; X is the parent class that declares the method
listeners() that calls parent::listeners($listeners) The same exception; the call is not reached
bindings() next to a routers() that was left The same exception, for routers()
parent::listeners($bindings->listeners()) inside bindings() of a class that extends ClassSchema Error: Call to undefined method Festi\Store\Schema\ClassSchema::listeners()

X is the class that declares the method. When a schema has several such methods the message names the first in the order relations, listeners, routers, rules, externalValues.

Without this check PHP would not stop such a class. Measured with the same classes and the check removed, on PHP 8.1, 8.2 and 8.3:

Code in the schema class Result of build()
protected function listeners(IListenersBuilder $listeners): IListenersBuilder An array whose listeners is empty. No error and no warning, so the store would work without its listeners
protected function externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder An array whose externalValues is empty, so the store would save its records without these values
bindings() next to a routers() that was left The sections of bindings() are built, routers is empty

How to find it

Save this as class-schema-bindings.php in the project root:

<?php
// Usage: php class-schema-bindings.php [--write] [--schema=Name] <dir> ...
// Lists, in every class that extends ClassSchema or a schema class found in
// the directories, each method relations(), listeners(), routers(), rules()
// and externalValues(): the section "ClassSchema declares five sections in
// bindings()" removes them. With --write it moves their bodies into one
// bindings() method of the class and lists the rest, which is changed by
// hand. --schema=Name adds a schema class whose file is not in the
// directories, such as one of a package.

const SKIPPED_DIRECTORIES = ['vendor', 'node_modules', '.git'];
const BLANK = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
const BRACES = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES];
const MODIFIERS = [
    T_PUBLIC,
    T_PROTECTED,
    T_PRIVATE,
    T_FINAL,
    T_STATIC,
    T_ABSTRACT,
];
const SECTIONS = [
    'relations'      => 'IRelationsBuilder',
    'listeners'      => 'IListenersBuilder',
    'routers'        => 'IRoutersBuilder',
    'rules'          => 'IRulesBuilder',
    'externalvalues' => 'IExternalValuesBuilder',
];
const GETTERS = ['externalvalues' => 'externalValues'];
const BINDINGS = 'Festi\\Store\\Schema\\Builder\\ISchemaBindings';
const BY_HAND = [
    'bindings' => 'the class has bindings() already',
    'parent'   => 'a parent schema class declares bindings() or a section',
    'shape'    => 'not a method with one parameter and a body',
    'return'   => 'its only return is not its last statement and parameter',
    'call'     => 'calls a section method, or uses $bindings or its own name',
    'class'    => 'another section method of the class is changed by hand',
];

function shortName(string $name): string
{
    return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}

function isToken(mixed $token, int ...$kinds): bool
{
    return is_array($token) && in_array($token[0], $kinds, true);
}

function text(mixed $token): string
{
    return is_array($token) ? $token[1] : $token;
}

function nextIndex(array $tokens, int $index): int
{
    do {
        $index++;
    } while (isset($tokens[$index]) && isToken($tokens[$index], ...BLANK));

    return $index;
}

function previousIndex(array $tokens, int $index): int
{
    do {
        $index--;
    } while ($index >= 0 && isToken($tokens[$index], ...BLANK));

    return $index;
}

function findFiles(string $directory): array
{
    $files = [];
    $entries = scandir($directory) ?: [];

    foreach (array_diff($entries, ['.', '..']) as $entry) {
        $path = rtrim($directory, '/').'/'.$entry;
        if (is_link($path)) {
            continue;
        }
        if (is_dir($path) && !in_array($entry, SKIPPED_DIRECTORIES, true)) {
            $files = array_merge($files, findFiles($path));
        } else if (is_file($path) && str_ends_with($entry, '.php')) {
            $files[] = $path;
        }
    }

    return $files;
}

// Returns the index of the brace that closes the one at $index.
function closingIndex(array $tokens, int $index): int
{
    $depth = 0;
    for ($count = count($tokens); $index < $count; $index++) {
        if ($tokens[$index] === '{' || isToken($tokens[$index], ...BRACES)) {
            $depth++;
        } else if ($tokens[$index] === '}' && --$depth === 0) {
            break;
        }
    }

    return $index;
}

// Returns the classes of a file that extend another class: name, parent and
// the token range of the body.
function readClasses(array $tokens): array
{
    $classes = [];

    foreach ($tokens as $index => $token) {
        $before = $tokens[previousIndex($tokens, $index)] ?? null;
        if (!isToken($token, T_CLASS) || isToken($before, T_DOUBLE_COLON)) {
            continue;
        }
        $name = $tokens[nextIndex($tokens, $index)];
        $class = ['name' => isToken($name, T_STRING) ? $name[1] : null];
        for ($from = $index; $tokens[$from] !== '{'; $from++) {
            $isFirst = !isset($class['parent']);
            if ($isFirst && isToken($tokens[$from], T_EXTENDS)) {
                $parent = $tokens[nextIndex($tokens, $from)];
                $class['parent'] = shortName($parent[1]);
            }
        }
        if (isset($class['parent'])) {
            $class += ['from' => $from, 'to' => closingIndex($tokens, $from)];
            $classes[] = $class;
        }
    }

    return $classes;
}

// Returns the methods a class declares itself, by their lower-case names:
// where the declaration starts, its parameter and the braces of its body.
function readMethods(array $tokens, array $class): array
{
    $methods = [];
    $depth = 0;

    for ($index = $class['from']; $index < $class['to']; $index++) {
        $token = $tokens[$index];
        if ($token === '{' || isToken($token, ...BRACES)) {
            $depth++;
        } else if ($token === '}') {
            $depth--;
        }
        $name = $tokens[nextIndex($tokens, $index)];
        if ($depth !== 1 || !isToken($token, T_FUNCTION) ||
            !isToken($name, T_STRING)) {
            continue;
        }

        $start = $index;
        $modifiers = [];
        while (isToken($tokens[previousIndex($tokens, $start)], ...MODIFIERS)) {
            $start = previousIndex($tokens, $start);
            $modifiers[] = $tokens[$start][0];
        }
        $doc = $start;
        while (isToken($tokens[$doc - 1], ...BLANK)) {
            $doc--;
            $start = isToken($tokens[$doc], T_DOC_COMMENT) ? $doc : $start;
        }

        $open = nextIndex($tokens, nextIndex($tokens, $index));
        $variable = $tokens[nextIndex($tokens, nextIndex($tokens, $open))];
        $close = $open;
        $parameters = [];
        while ($tokens[$close] !== ')') {
            $close++;
            if (isToken($tokens[$close], T_VARIABLE)) {
                $parameters[] = $tokens[$close][1];
            }
        }
        $body = $close;
        while ($tokens[$body] !== '{' && $tokens[$body] !== ';') {
            $body++;
        }

        $isPlain = count($parameters) === 1 && $tokens[$body] === '{' &&
            isToken($variable, T_VARIABLE) &&
            $tokens[previousIndex($tokens, $close)] === $variable &&
            $tokens[previousIndex($tokens, $start)] !== ']' &&
            !array_intersect($modifiers, [T_STATIC, T_ABSTRACT, T_PRIVATE]);

        $methods[strtolower($name[1])] = [
            'name'     => $name[1],
            'line'     => $name[2],
            'start'    => $start,
            'function' => $index,
            'variable' => $isPlain ? $variable[1] : null,
            'from'     => $body,
            'to'       => $tokens[$body] === '{' ?
                closingIndex($tokens, $body) : $body,
        ];
    }

    return $methods;
}

// Returns why the body of a section method is not moved, or null.
function findObstacle(array $tokens, array $method): ?string
{
    if ($method['variable'] === null) {
        return 'shape';
    }

    $returns = [];
    for ($index = $method['from'] + 1; $index < $method['to']; $index++) {
        $token = $tokens[$index];
        $word = strtolower(text($token));
        $isName = isToken($token, T_STRING) && isset(SECTIONS[$word]) &&
            $tokens[nextIndex($tokens, $index)] === '(';
        if ($isName || isToken($token, T_FUNC_C, T_METHOD_C) ||
            $word === '$bindings' || $word === 'func_get_args') {
            return 'call';
        }
        if (isToken($token, T_RETURN)) {
            $returns[] = $index;
        }
    }

    $last = previousIndex($tokens, $method['to']);
    $statement = $last;
    while ($statement > $method['from'] &&
        !in_array($tokens[$statement - 1], [';', '{', '}'], true)) {
        $statement--;
    }
    $statement = nextIndex($tokens, $statement - 1);
    $value = $tokens[nextIndex($tokens, $statement)];

    $isLastReturn = $returns === [$statement] && $tokens[$last] === ';' &&
        isToken($value, T_VARIABLE) && $value[1] === $method['variable'];

    return $isLastReturn ? null : 'return';
}

// Returns the statements of a section method without its return, after a
// line that takes the builder from the bindings.
function moveBody(array $tokens, array $method, string $indent): string
{
    $last = previousIndex($tokens, $method['to']);
    $return = $last;
    while (!isToken($tokens[$return], T_RETURN)) {
        $return--;
    }

    $parts = array_slice(
        $tokens,
        $method['from'] + 1,
        $return - $method['from'] - 1
    );
    if (nextIndex($tokens, nextIndex($tokens, $return)) !== $last) {
        $parts = array_merge($parts, array_slice(
            $tokens,
            nextIndex($tokens, $return),
            $last - nextIndex($tokens, $return) + 1
        ));
    }

    $body = rtrim(implode('', array_map('text', $parts)));
    if ($body === '') {
        return '';
    }

    $name = strtolower($method['name']);

    return $indent.$method['variable'].' = $bindings->'.
        (GETTERS[$name] ?? $name).'();'.$body;
}

// Returns the source with a `use` of ISchemaBindings in its place among
// the imports.
function addImport(string $source): string
{
    $pattern = '/^use\s+([\w\\\\]+);[^\n]*\n/m';
    preg_match_all($pattern, $source, $uses, PREG_OFFSET_CAPTURE);
    $names = array_column($uses[1], 0);

    if (in_array(BINDINGS, $names, true)) {
        return $source;
    }

    $at = end($uses[0])[1] + strlen(end($uses[0])[0]);
    foreach ($names as $index => $name) {
        if (strcasecmp($name, BINDINGS) > 0) {
            $at = $uses[0][$index][1];
            break;
        }
    }

    return substr($source, 0, $at).'use '.BINDINGS.";\n".
        substr($source, $at);
}

// Removes the imports of the builder interfaces that nothing in the file
// names any more.
function removeUnusedImports(string $source): string
{
    foreach (SECTIONS as $interface) {
        $pattern = '/^use\s+[\w\\\\]*\\\\'.$interface.';[^\n]*\n/m';
        $rest = preg_replace($pattern, '', $source, 1);
        if (!preg_match('/\b'.$interface.'\b/', $rest)) {
            $source = $rest;
        }
    }

    return $source;
}

// Returns the tokens of a file and its classes that extend another class;
// null when the file has none or PHP cannot parse it.
function readSource(string $path): ?array
{
    $source = file_get_contents($path);
    if (stripos($source, 'extends') === false) {
        return null;
    }

    try {
        $tokens = token_get_all($source, TOKEN_PARSE);
    } catch (ParseError $error) {
        fwrite(STDERR, $path.': not parsed, '.$error->getMessage()."\n");

        return null;
    }

    $classes = readClasses($tokens);

    return $classes ? [$tokens, $classes, $source] : null;
}

// Returns the source of a class with its movable section methods replaced
// by one bindings() method; $moved is [lower-case name => method].
function rewriteClass(array $tokens, array $moved, string $type): string
{
    $offsets = [0];
    foreach ($tokens as $token) {
        $offsets[] = end($offsets) + strlen(text($token));
    }
    $source = implode('', array_map('text', $tokens));

    $first = reset($moved);
    $at = $offsets[$first['function']];
    $line = substr($source, 0, $at);
    $line = substr($line, (int) strrpos($line, "\n") + 1);
    $indent = substr($line, 0, strlen($line) - strlen(ltrim($line)));
    $inner = $indent.(str_contains($indent, "\t") ? "\t" : '    ');

    $blocks = [];
    foreach ($moved as $method) {
        $blocks[] = moveBody($tokens, $method, $inner);
    }
    $blocks = array_filter($blocks);

    $head = substr(
        $source,
        $offsets[$first['start']],
        $offsets[$first['function']] - $offsets[$first['start']]
    );
    $gap = $offsets[previousIndex($tokens, $first['from']) + 1];
    $brace = substr($source, $gap, $offsets[$first['from']] - $gap);
    $bindings = $head.'function bindings('.$type.' $bindings): '.$type.
        $brace."{\n".implode("\n\n", $blocks)."\n\n".$inner.
        'return $bindings;'."\n".$indent.'}';

    foreach (array_reverse($moved) as $method) {
        $from = $method['start'];
        $isFirst = $method === $first && $blocks;
        if (!$isFirst && isToken($tokens[$from - 1], T_WHITESPACE)) {
            $from--;
        }
        $source = substr($source, 0, $offsets[$from]).
            ($isFirst ? $bindings : '').
            substr($source, $offsets[$method['to'] + 1]);
    }

    return $source;
}

error_reporting(E_ALL & ~E_DEPRECATED);

$arguments = array_slice($argv, 1);
$isWrite = in_array('--write', $arguments, true);
$schemas = ['ClassSchema' => true];
$files = [];

foreach ($arguments as $argument) {
    if (str_starts_with($argument, '--schema=')) {
        $schemas[shortName(substr($argument, 9))] = true;
    } else if ($argument !== '--write') {
        $files = array_merge($files, findFiles($argument));
    }
}

$bases = count($schemas);
$bound = array_merge(array_keys(SECTIONS), ['bindings']);
$parents = [];
$declared = [];
foreach ($files as $path) {
    [$tokens, $classes] = readSource($path) ?? [[], []];
    foreach ($classes as $class) {
        $parents[$path][] = [$class['name'], $class['parent']];
        if ($class['name'] !== null) {
            $names = array_keys(readMethods($tokens, $class));
            $declared[$class['name']] = [
                $class['parent'],
                (bool) array_intersect($names, $bound),
            ];
        }
    }
}

do {
    $count = count($schemas);
    foreach ($parents as $pairs) {
        foreach ($pairs as [$name, $parent]) {
            if ($name !== null && isset($schemas[$parent])) {
                $schemas[$name] = true;
            }
        }
    }
} while (count($schemas) > $count);

// Tells whether a schema class of the project above $parent declares
// bindings() or one of the five sections.
$hasBindingParent = function (string $parent) use ($declared): bool {
    for ($depth = 0; isset($declared[$parent]) && $depth < 50; $depth++) {
        if ($declared[$parent][1]) {
            return true;
        }
        $parent = $declared[$parent][0];
    }

    return false;
};

$total = ['moved' => 0, 'by hand' => 0, 'files' => 0];
foreach ($parents as $path => $pairs) {
    $isSchemaFile = false;
    foreach ($pairs as [$name, $parent]) {
        $isSchemaFile = $isSchemaFile || isset($schemas[$parent]);
    }
    if (!$isSchemaFile) {
        continue;
    }

    [$tokens, $classes, $source] = readSource($path);
    $isChanged = false;
    $hasImports = preg_match('/^use\s+[\w\\\\]+;/m', $source) === 1;
    $type = $hasImports ? shortName(BINDINGS) : '\\'.BINDINGS;

    // The last class of the file first: a rewrite keeps the token indexes
    // of the classes above it.
    foreach (array_reverse($classes) as $class) {
        if (!isset($schemas[$class['parent']])) {
            continue;
        }
        $methods = readMethods($tokens, $class);
        $sections = array_intersect_key($methods, SECTIONS);
        $obstacles = [];

        foreach ($sections as $key => $method) {
            $obstacles[$key] = findObstacle($tokens, $method);
            if (isset($methods['bindings'])) {
                $obstacles[$key] = 'bindings';
            } else if ($hasBindingParent($class['parent'])) {
                $obstacles[$key] = 'parent';
            }
        }
        $isMovable = !array_filter($obstacles);

        foreach ($sections as $key => $method) {
            $obstacle = $obstacles[$key] ?? ($isMovable ? null : 'class');
            $total[$obstacle === null ? 'moved' : 'by hand']++;
            printf(
                "%s:%d: [%s] %s::%s()\n",
                $path,
                $method['line'],
                $obstacle === null ?
                    'to bindings()'.($isWrite ? ', rewritten' : '') :
                    'by hand: '.BY_HAND[$obstacle],
                $class['name'] ?? 'class@anonymous',
                $method['name']
            );
        }

        if ($isWrite && $isMovable && $sections) {
            $source = rewriteClass($tokens, $sections, $type);
            $tokens = token_get_all($source);
            $isChanged = true;
        }
    }

    if ($isChanged) {
        $isNamed = str_contains($source, $type.' $bindings');
        $source = $hasImports && $isNamed ? addImport($source) : $source;
        file_put_contents($path, removeUnusedImports($source));
        $total['files']++;
    }
}

printf(
    "%d schema classes; section methods to move to bindings(): %d %s; ".
    "to change by hand: %d\n",
    count($schemas) - $bases,
    $total['moved'],
    $isWrite ? 'rewritten in '.$total['files'].' files' : 'found',
    $total['by hand']
);

Run it in the project root on the directories that hold the code of the project. Without --write it changes nothing:

php class-schema-bindings.php .

It prints one line for each of the five methods a schema class declares and a total:

./plugins/School/domain/store/schema/SchoolsSchema.php:358: [to bindings()] SchoolsSchema::listeners()
./plugins/Apps/domain/store/schema/ManageAppsSchema.php:168: [to bindings()] ManageAppsSchema::externalValues()
./plugins/Reports/ReportsSchema.php:40: [by hand: its only return is not its last statement and parameter] ReportsSchema::listeners()
./plugins/Reports/ReportsSchema.php:61: [by hand: another section method of the class is changed by hand] ReportsSchema::routers()
73 schema classes; section methods to move to bindings(): 2 found; to change by hand: 2

0 found; to change by hand: 0 means no work. The script reads the extends of every class in the directories and follows it up to ClassSchema, so it lists a schema that extends a schema class of the project, and an anonymous class that extends one, and leaves out a class that has a rules() or a listeners() and is not a schema. It does not look into vendor, node_modules and .git. It does not see:

  • A schema class whose parent is in a package: name the parent with --schema=Name, as in php class-schema-bindings.php --schema=PackageSchema ..
  • A trait that a schema class uses.
  • A method declared as function &listeners(...).

The exception above reports these when the schema is built.

In the framework the script prints eight methods: tests/Resources/Schema/UncalledListenersSchema.php and seven anonymous schemas of tests/Bundle/Store/Schema/ClassSchemaTest.php keep such a method to test the exception, and stay as they are. In the projects and shared plugins it was run on it finds 8 methods, in 8 of the 73 schema classes of one project: five externalValues(), two listeners() and one routers(), all labelled [to bindings()]. --write rewrote the eight; a second run found nothing.

What to do

  1. Run the script with --write:
php class-schema-bindings.php --write .

In every class it labels [to bindings()] it replaces the first of the five methods with bindings(), moves the statements of the others into it in the order they have in the file, and removes them. Each moved body starts with a line that takes its builder from the bindings under the name the parameter had, such as $listeners = $bindings->listeners();, so the statements stay as they were; the return of the parameter is left out and return $bindings; ends the method. It adds use Festi\Store\Schema\Builder\ISchemaBindings; in its place among the imports, or writes the full name in a file without imports, and removes the import of a builder interface nothing in the file names any more. A method that only returns its parameter is removed. Nothing else in the file changes. Review the result with git diff; run the script again and it prints only what is left.

  1. The docblock of the first moved method stays above bindings(); the docblocks of the other moved methods are removed with them. Correct a docblock that names the old parameter.

  2. Change by hand what the script labels [by hand: ...]. It rewrites no method of a class that has one such line:

Label What to do
its only return is not its last statement and parameter The method returns early, returns from a closure, or returns something other than its parameter. Move its statements into bindings() and replace an early return $listeners; with a condition around the statements
calls a section method, or uses $bindings or its own name The method calls parent::listeners() or another of the five methods, or has a variable $bindings. Call parent::bindings($bindings) once at the start of bindings()
a parent schema class declares bindings() or a section A schema of the project above this class declares bindings too. Write bindings() with parent::bindings($bindings); first when the class adds to the sections of its parent. Leave the call out for a section method that replaced the one of the parent without calling it, and declare in the class what it still needs from the parent: bindings() replaces all five sections together
the class has bindings() already Move the statements into the bindings() the class has
not a method with one parameter and a body A static or abstract method, a method with another parameter list, or a method that has the name for another purpose. Rename a method that is not a section method: the five names are reserved
another section method of the class is changed by hand Move this method together with the one that has another label
  1. Check a schema whose section methods depend on the order they are called in: bindings() runs after the nine section methods.

Before:

<?php

use Festi\Store\Schema\Builder\IExternalValuesBuilder;
use Festi\Store\Schema\Builder\IFieldsBuilder;
use Festi\Store\Schema\Builder\IListenersBuilder;
use Festi\Store\Schema\Builder\IRoutersBuilder;
use Festi\Store\Schema\Builder\ITableBuilder;
use Festi\Store\Schema\ClassSchema;

class StudyGroupsSchema extends ClassSchema
{
    public function __construct(private int $_idSchool)
    {
    }

    protected function table(ITableBuilder $table): ITableBuilder
    {
        return $table->name('study_groups')->primaryKey('id');
    }

    protected function routers(IRoutersBuilder $routers): IRoutersBuilder
    {
        $routers->add('study_groups')
            ->joinStore('grade_groups')
            ->type('LEFT')
            ->on('grade_groups.id_school = study_groups.id_school');

        return $routers;
    }

    protected function fields(IFieldsBuilder $fields): IFieldsBuilder
    {
        $fields->add('caption')->type(TextField::class);

        return $fields;
    }

    protected function listeners(IListenersBuilder $listeners): IListenersBuilder
    {
        $listeners->add(Store::EVENT_INSERT)
            ->plugin('StudyGroups')
            ->method('onAddGroup');

        return $listeners;
    }

    protected function externalValues(
        IExternalValuesBuilder $values
    ): IExternalValuesBuilder
    {
        return $values->add('id_school', $this->_idSchool);
    }
}

After, as the script writes it:

<?php

use Festi\Store\Schema\Builder\IFieldsBuilder;
use Festi\Store\Schema\Builder\ISchemaBindings;
use Festi\Store\Schema\Builder\ITableBuilder;
use Festi\Store\Schema\ClassSchema;

class StudyGroupsSchema extends ClassSchema
{
    public function __construct(private int $_idSchool)
    {
    }

    protected function table(ITableBuilder $table): ITableBuilder
    {
        return $table->name('study_groups')->primaryKey('id');
    }

    protected function bindings(ISchemaBindings $bindings): ISchemaBindings
    {
        $routers = $bindings->routers();
        $routers->add('study_groups')
            ->joinStore('grade_groups')
            ->type('LEFT')
            ->on('grade_groups.id_school = study_groups.id_school');

        $listeners = $bindings->listeners();
        $listeners->add(Store::EVENT_INSERT)
            ->plugin('StudyGroups')
            ->method('onAddGroup');

        $values = $bindings->externalValues();
        $values->add('id_school', $this->_idSchool);

        return $bindings;
    }

    protected function fields(IFieldsBuilder $fields): IFieldsBuilder
    {
        $fields->add('caption')->type(TextField::class);

        return $fields;
    }
}

The local variables are not needed. The same method written by hand:

<?php

use Festi\Store\Schema\Builder\ISchemaBindings;
use Festi\Store\Schema\ClassSchema;

class StudyGroupsSchema extends ClassSchema
{
    public function __construct(private int $_idSchool)
    {
    }

    protected function bindings(ISchemaBindings $bindings): ISchemaBindings
    {
        $bindings->routers()->add('study_groups')
            ->joinStore('grade_groups')
            ->type('LEFT')
            ->on('grade_groups.id_school = study_groups.id_school');

        $bindings->listeners()->add(Store::EVENT_INSERT)
            ->plugin('StudyGroups')
            ->method('onAddGroup');

        $bindings->externalValues()->add('id_school', $this->_idSchool);

        return $bindings;
    }
}

A schema that adds to the bindings of a schema of the project, before:

<?php

use Festi\Store\Schema\Builder\IListenersBuilder;

class ArchivedStudyGroupsSchema extends StudyGroupsSchema
{
    protected function listeners(IListenersBuilder $listeners): IListenersBuilder
    {
        parent::listeners($listeners)
            ->add(Store::EVENT_REMOVE)
            ->plugin('StudyGroups')
            ->method('onRemoveGroup');

        return $listeners;
    }
}

After:

<?php

use Festi\Store\Schema\Builder\ISchemaBindings;

class ArchivedStudyGroupsSchema extends StudyGroupsSchema
{
    protected function bindings(ISchemaBindings $bindings): ISchemaBindings
    {
        parent::bindings($bindings)
            ->listeners()
            ->add(Store::EVENT_REMOVE)
            ->plugin('StudyGroups')
            ->method('onRemoveGroup');

        return $bindings;
    }
}

tests/Resources/Schema/ParityFullSchema.php and tests/Resources/Schema/SampleUsersSchema.php are schemas with bindings() in the framework, rewritten by the script.

The last untyped members have native types

CHANGELOG entry: The eighteen methods and two properties that kept a signature without a native type declare it now.

What changed

Eighteen methods and two properties kept a signature without a native type until now, because a plugin, a package or a project overrides them without one. They declare the type now, and no member of the framework is left without one.

Member Before Now
AbstractPlugin::onInit(), ObjectPlugin::onInit() public function onInit() public function onInit(): void
ObjectPlugin::$object protected $object protected ?IDataAccessObject $object = null
ISystemPlugin::getResponseModel(), ApiPlugin::getResponseModel() public function getResponseModel($call, $regs = []) public function getResponseModel(array $call, array $regs = []): Response
ISystemPlugin::install(), ApiPlugin::install() public function install() public function install(): void
ISystemPlugin::onDisplayMain(), ApiPlugin::onDisplayMain() public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = []) public function onDisplayMain(Response &$response, string\|false $storeName = false, string\|false $pluginName = false, array $params = []): ?bool
ISystemObject::getSettings(), SystemObject::getSettings() public function getSettings() public function getSettings(): array
ISystemObjectUrlRules::getUrlRules(), SystemObject::getUrlRules() public function getUrlRules($search) public function getUrlRules(array $search): array
ISystemObjectUrlRules::addUrlRule(), SystemObject::addUrlRule() public function addUrlRule($values) public function addUrlRule(array $values): mixed
DefaultUser::get() public function get($name) public function get(string $name): mixed
DefaultUser::set() public function set($name, $value) public function set(string $name, mixed $value): mixed
DefaultUser::getID() public function getID() public function getID(): mixed
DefaultUser::getRole() public function getRole() public function getRole(): mixed
DefaultUser::getSessionData() public function getSessionData() public function getSessionData(): array
DefaultUser::$requiredFields protected $requiredFields protected array $requiredFields
AbstractField::getFilterTemplateName() protected function getFilterTemplateName() protected function getFilterTemplateName(): string
AbstractField::getInputType() public function getInputType() public function getInputType(): string
HandlerField::callHandlerMethod() public function callHandlerMethod($params, $methodPostfix) public function callHandlerMethod(array $params, string $methodPostfix): mixed
PriceField::getCurrencySymbol() protected function getCurrencySymbol() protected function getCurrencySymbol(): string\|false
IProxyRepository::insert(), OpenApiProxy::insert() public function insert(array $values) public function insert(array $values): mixed
IStore::getOption() public function getOption(string $name) public function getOption(string $name): mixed
PermissionsException::$code protected $code = 403 No property. A constructor sets the code

What the types say:

  • void: onInit() and install() return nothing. An override that has return parent::onInit(); or return true; does not compile.
  • ?bool: onDisplayMain() returns true, false or null.
  • mixed: any value, but a value. PHP throws a TypeError when a method with a return type other than void ends without return <value>;, also when the type is mixed or ?bool. return null; is a value.
  • ?IDataAccessObject and array of the two properties are exact: a redeclaration has to repeat the type as it is written here. PHP accepts no class that implements IDataAccessObject in its place.
  • An override may leave its parameters without a type, as before.

PermissionsException no longer redeclares $code. It declares a constructor with the five parameters of SystemException, which sets the code 403 when the exception has none. The code is the public constant PermissionsException::CODE_FORBIDDEN, which a class that extends the exception can redeclare to have another default:

public function __construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string|false|null $displayMessage = false, ?Throwable $previous = null)

Measured before and after the change, with the same result on PHP 8.1, 8.2 and 8.3:

Code getCode() before getCode() now
new PermissionsException(), new PermissionsException($message), new PermissionsException($message, 0) 403 403
new PermissionsException($message, 401) 401 401
new PermissionsException(null, 0, new ExceptionContext(), $shown) 403 403
A subclass that declares protected $code = 401;, created without a code or with the code 0 401 401
The same subclass, created with the code 500 500 500
A subclass whose constructor does not call parent::__construct() 403 0
An exception created without its constructor: ReflectionClass::newInstanceWithoutConstructor(), a PHPUnit mock 403 0

A call with the arguments of the old signature is refused as before; the TypeError names PermissionsException::__construct() where it named SystemException::__construct().

These do not change:

  • The values the framework passes to these methods and the values it gets back. A method bound to a url, onDisplayMain() among them, is still called in coercive mode with the strings the url rule captured: see The framework calls project code in coercive mode.
  • An override that already declares the type. SqlProxy::insert(), CassandraProxy::insert(), Store::getOption(), DatetimeField::getFilterTemplateName(), CheckboxField::getFilterTemplateName(), PasswordField::getInputType() and EmailField::getInputType() declared it before, so a class that extends one of them was already held to it.
  • A name given to DefaultUser::get() or set() as a number by a file without strict_types: PHP converts it to a string, and the session array keeps 5 and '5' under one key.

Who is affected

Project class What PHP checks in it
A plugin: a class that extends AbstractPlugin, ObjectPlugin, DisplayPlugin, StagePlugin or a plugin class onInit(); $object when the plugin has an object
A system plugin: a class that implements ISystemPlugin or extends ApiPlugin, such as the Jimbo plugin, and the traits it uses getResponseModel(), install(), onDisplayMain()
A system object: a class that extends SystemObject or implements ISystemObject getSettings(), getUrlRules(), addUrlRule()
A user class: a class that extends DefaultUser get(), set(), getID(), getRole(), getSessionData(), $requiredFields
A field class getFilterTemplateName(), getInputType(); callHandlerMethod() under HandlerField; getCurrencySymbol() under PriceField
A proxy class that extends OpenApiProxy or StoreProxy, or implements IProxy insert()
A class that implements IStore without extending Store getOption()
A class that extends PermissionsException Its code, when the constructor of the class does not call parent::__construct()

A class is affected only when it declares one of these members. A class that declares none of them, and code that only calls them with the types of the table, is not affected.

PHP stops an affected class. The text is the same on PHP 8.1, 8.2 and 8.3; php -l finds the two errors that are marked so, the class-load check finds the others of the first seven rows.

Code in the project class Error
public function getSettings() in a class that extends SystemObject: an override without the return type Fatal error: Declaration of JimboObject::getSettings() must be compatible with SystemObject::getSettings(): array
public function onDisplayMain(Response &$response): void: a return type the framework type does not include; void is not part of ?bool or mixed Fatal error: Declaration of X::onDisplayMain(Response &$response): void must be compatible with ISystemPlugin::onDisplayMain(Response &$response, string\|false $storeName = false, string\|false $pluginName = false, array $params = []): ?bool
public function get(int $name): a parameter type that does not include the framework type Fatal error: Declaration of X::get(int $name): mixed must be compatible with DefaultUser::get(string $name): mixed
protected $object;, also with @var UsersObject above it Fatal error: Type of X::$object must be ?IDataAccessObject (as in class ObjectPlugin)
protected ?UsersObject $object = null; The same error
protected $requiredFields = ['auth', 'id']; Fatal error: Type of X::$requiredFields must be array (as in class DefaultUser)
public function onInit(): void with return parent::onInit(); or another return <value>; Fatal error: A void function must not return a value (php -l)
public function onDisplayMain(...): ?bool with a return; Fatal error: A function with return type must return a value (did you mean "return null;" instead of "return;"?) (php -l)
public function onDisplayMain(...): ?bool with an empty body, or a path that reaches the end of the method TypeError: X::onDisplayMain(): Return value must be of type ?bool, none returned, when the method is called
$this->object = new stdClass(); in a plugin TypeError: Cannot assign stdClass to property ObjectPlugin::$object of type ?IDataAccessObject; the message names the plugin class when that class redeclares the property
protected ?IDataAccessObject $object; without = null, read before onInit() has run Error: Typed property X::$object must not be accessed before initialization
$user->get(null), $object->getUrlRules('id = 1'), $field->callHandlerMethod($params, null) TypeError: DefaultUser::get(): Argument #1 ($name) must be of type string, null given, and the same for the other typed parameters

Two things PHP checks in the other direction, against the release before this one:

  • A return type on an override is valid while the framework method has none. So the return types of this section can be added before the framework is updated, and a plugin or a package that has them works with both releases.
  • A type on a redeclared property, and a parameter type on an override, are not valid while the framework member has none: Fatal error: Type of X::$object must not be defined (as in class ObjectPlugin). A class that has to work with both releases declares neither: it removes the redeclaration of the property (see "What to do") and leaves the parameters without a type.

How to find it

Run the class-load check: it reports every class PHP refuses.

To list every declaration, and to add the return types and the property types, save this as native-type-overrides.php in the project root:

<?php
// Usage: php native-type-overrides.php [--write] [--vendor] <dir> ...
// Lists, in every class, interface and trait of the directories that
// extends, implements or is used by a framework class of the section
// "The last untyped members have native types", each member PHP refuses:
//   [return]   an override without the return type: --write adds it
//   [property] a redeclared property without the type: --write adds it
//   [by hand]  a type PHP refuses, a `return <value>;` in a void method,
//              a method that has to return a value and does not
// The directories named vendor are read to find parent classes and are
// not reported; --vendor reports them too.

const SKIPPED_DIRECTORIES = ['node_modules', '.git'];
const BLANK = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
const BRACES = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES];
const CLASS_KINDS = [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM];
const NAME_KINDS = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED];
const MODIFIERS = [
    T_PUBLIC, T_PROTECTED, T_PRIVATE, T_VAR, T_STATIC, T_READONLY,
    T_ABSTRACT, T_FINAL,
];
const REFERENCES = [
    T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG,
    T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG,
];
const NOT_A_TYPE = [
    T_PUBLIC, T_PROTECTED, T_PRIVATE, T_READONLY, T_ELLIPSIS,
    T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG,
];
const BUILT_IN = [
    'array', 'bool', 'callable', 'false', 'float', 'int', 'iterable',
    'mixed', 'never', 'null', 'object', 'string', 'true', 'void',
];
const FIELDS = [
    'AbstractField', 'AudioField', 'AutocompleteField', 'CheckboxField',
    'CompositeField', 'DatetimeField', 'EmailField', 'FileField',
    'ForeignKeyField', 'HandlerField', 'HiddenField', 'ImageField',
    'Many2manyField', 'Md5Field', 'MultiAutocompleteField',
    'MultiFileField', 'NumberField', 'NumeratorField', 'PasswordField',
    'PercentField', 'PriceField', 'RadioField', 'ReadonlyField',
    'SelectField', 'SerializeField', 'TagsField', 'TextareaField',
    'TextcomboboxField', 'TextField', 'TimestampField',
];
const PROXIES = [
    'IProxyRepository', 'IProxy', 'StoreProxy', 'SqlProxy', 'MysqlProxy',
    'PgsqlProxy', 'MssqlProxy', 'CassandraProxy', 'OpenApiProxy',
];
const OBJECT_PLUGINS = [
    'ObjectPlugin', 'ApiPlugin', 'DisplayPlugin', 'StagePlugin',
];

// Per family: the framework classes it starts from, the methods as
// name => [parameter types, return type], the properties as name => type.
const FAMILIES = [
    'plugin' => [
        ['AbstractPlugin', ...OBJECT_PLUGINS],
        ['onInit' => [[], 'void']],
        [],
    ],
    'object plugin' => [
        OBJECT_PLUGINS,
        [],
        ['$object' => '?IDataAccessObject'],
    ],
    'system plugin' => [
        ['ISystemPlugin', 'ApiPlugin'],
        [
            'getResponseModel' => [['array', 'array'], 'Response'],
            'install'          => [[], 'void'],
            'onDisplayMain'    => [
                [null, 'string|false', 'string|false', 'array'],
                '?bool',
            ],
        ],
        [],
    ],
    'system object' => [
        ['ISystemObject', 'ISystemObjectUrlRules', 'SystemObject'],
        [
            'getSettings' => [[], 'array'],
            'getUrlRules' => [['array'], 'array'],
            'addUrlRule'  => [['array'], 'mixed'],
        ],
        [],
    ],
    'user' => [
        ['DefaultUser'],
        [
            'get'            => [['string'], 'mixed'],
            'set'            => [['string', 'mixed'], 'mixed'],
            'getID'          => [[], 'mixed'],
            'getRole'        => [[], 'mixed'],
            'getSessionData' => [[], 'array'],
        ],
        ['$requiredFields' => 'array'],
    ],
    'field' => [
        FIELDS,
        [
            'getFilterTemplateName' => [[], 'string'],
            'getInputType'          => [[], 'string'],
        ],
        [],
    ],
    'handler field' => [
        ['HandlerField'],
        ['callHandlerMethod' => [['array', 'string'], 'mixed']],
        [],
    ],
    'price field' => [
        ['PriceField'],
        ['getCurrencySymbol' => [[], 'string|false']],
        [],
    ],
    'proxy' => [
        PROXIES,
        ['insert' => [['array'], 'mixed']],
        [],
    ],
    'store' => [
        ['IStore'],
        ['getOption' => [['string'], 'mixed']],
        [],
    ],
];

function shortName(string $name): string
{
    return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}

function isToken(mixed $token, int ...$kinds): bool
{
    return is_array($token) && in_array($token[0], $kinds, true);
}

function nextIndex(array $tokens, int $index): int
{
    do {
        $index++;
    } while (isset($tokens[$index]) && isToken($tokens[$index], ...BLANK));

    return $index;
}

function previousIndex(array $tokens, int $index): int
{
    do {
        $index--;
    } while ($index >= 0 && isToken($tokens[$index], ...BLANK));

    return $index;
}

// Returns the index of the bracket that closes the one at $index.
function closingIndex(array $tokens, int $index, string $close): int
{
    $open = $tokens[$index];
    $depth = 0;

    for ($count = count($tokens); $index < $count; $index++) {
        $token = $tokens[$index];
        $isBrace = $open === '{' && isToken($token, ...BRACES);
        if ($token === $open || $isBrace) {
            $depth++;
        } else if ($token === $close && --$depth === 0) {
            return $index;
        }
    }

    return $count - 1;
}

function findFiles(string $directory): array
{
    $files = [];
    $entries = scandir($directory) ?: [];

    foreach (array_diff($entries, ['.', '..']) as $entry) {
        $path = rtrim($directory, '/').'/'.$entry;
        if (is_link($path)) {
            continue;
        }
        if (is_dir($path) && !in_array($entry, SKIPPED_DIRECTORIES, true)) {
            $files = array_merge($files, findFiles($path));
        } else if (is_file($path) && str_ends_with($entry, '.php')) {
            $files[] = $path;
        }
    }

    return $files;
}

function typeText(array $tokens, int $from, int $to): string
{
    $text = '';

    for ($index = $from; $index <= $to; $index++) {
        $token = $tokens[$index];
        if (!isToken($token, ...BLANK, ...NOT_A_TYPE)) {
            $text .= is_array($token) ? $token[1] : $token;
        }
    }

    return $text;
}

// Returns the members of a type: '?Response' gives ['response', 'null'].
function typeMembers(string $type): array
{
    $type = strtolower(str_replace(['(', ')', ' '], '', $type));
    if (str_starts_with($type, '?')) {
        $type = substr($type, 1).'|null';
    }

    return array_map('shortName', explode('|', $type));
}

function isCovered(string $member, array $members): bool
{
    $isBool = in_array($member, ['true', 'false'], true);

    return in_array($member, $members, true) ||
        in_array('mixed', $members, true) ||
        ($isBool && in_array('bool', $members, true));
}

function isReturnAccepted(string $declared, string $parent): bool
{
    $declared = typeMembers($declared);
    $parent = typeMembers($parent);

    if ($declared === ['never']) {
        return true;
    }
    if ($parent === ['void'] || $declared === ['void']) {
        return $declared === $parent;
    }

    foreach ($declared as $member) {
        $isClass = !in_array($member, BUILT_IN, true);
        $isParentClass = (bool) array_diff($parent, BUILT_IN);
        if (!isCovered($member, $parent) && !($isClass && $isParentClass)) {
            return false;
        }
    }

    return true;
}

function isParameterAccepted(string $declared, string $parent): bool
{
    $declared = typeMembers($declared);

    foreach (typeMembers($parent) as $member) {
        if (!isCovered($member, $declared)) {
            return false;
        }
    }

    return true;
}

// Returns the class, interface, trait and enum declarations of a file:
// name, the names after extends and implements, and the body range.
function readClasses(array $tokens): array
{
    $classes = [];

    foreach ($tokens as $index => $token) {
        if (!isToken($token, ...CLASS_KINDS)) {
            continue;
        }
        $before = $tokens[previousIndex($tokens, $index)] ?? null;
        $name = $tokens[nextIndex($tokens, $index)] ?? null;
        $isEnumWord = isToken($token, T_ENUM) && !isToken($name, T_STRING);
        if (isToken($before, T_DOUBLE_COLON) || $isEnumWord) {
            continue;
        }

        $class = [
            'name'    => isToken($name, T_STRING) ? $name[1] : '(anonymous)',
            'parents' => [],
            'extends' => [],
        ];
        $from = $index + 1;
        if ($tokens[nextIndex($tokens, $index)] === '(') {
            $from = closingIndex($tokens, nextIndex($tokens, $index), ')');
        }
        $list = null;
        while (isset($tokens[$from]) && $tokens[$from] !== '{') {
            if (isToken($tokens[$from], T_EXTENDS, T_IMPLEMENTS)) {
                $list = isToken($tokens[$from], T_EXTENDS) &&
                    isToken($token, T_CLASS) ? 'extends' : 'parents';
            } else if ($list && isToken($tokens[$from], ...NAME_KINDS)) {
                $class['parents'][] = shortName($tokens[$from][1]);
                $class[$list][] = shortName($tokens[$from][1]);
            }
            $from++;
        }

        $class['from'] = $from;
        $class['to'] = closingIndex($tokens, $from, '}');
        $classes[] = $class;
    }

    return $classes;
}

// Returns the method that starts at the `function` token at $index.
function readMethod(array $tokens, int $index): array
{
    $start = $index;
    while (isToken($tokens[previousIndex($tokens, $start)], ...MODIFIERS)) {
        $start = previousIndex($tokens, $start);
    }

    $name = nextIndex($tokens, $index);
    if (isToken($tokens[$name], ...REFERENCES)) {
        $name = nextIndex($tokens, $name);
    }
    $open = nextIndex($tokens, $name);
    $close = closingIndex($tokens, $open, ')');

    $parameters = [];
    $from = $open + 1;
    $depth = 0;
    $isDefault = false;
    for ($at = $open + 1; $at < $close; $at++) {
        $token = $tokens[$at];
        if (in_array($token, ['(', '['], true)) {
            $depth++;
        } else if (in_array($token, [')', ']'], true)) {
            $depth--;
        } else if ($depth === 0 && $token === ',') {
            $from = $at + 1;
            $isDefault = false;
        } else if ($depth === 0 && !$isDefault && isToken($token, T_VARIABLE)) {
            $parameters[] = typeText($tokens, $from, $at - 1);
            $isDefault = true;
        }
    }

    $end = $close;
    $return = '';
    $after = nextIndex($tokens, $close);
    if ($tokens[$after] === ':') {
        $end = $after;
        while (!in_array($tokens[$end + 1] ?? ';', ['{', ';'], true)) {
            $end++;
        }
        $end = previousIndex($tokens, $end + 1);
        $return = typeText($tokens, $after + 1, $end);
        $after = nextIndex($tokens, $end);
    }

    $text = '';
    for ($at = $start; $at <= $end; $at++) {
        $text .= is_array($tokens[$at]) ? $tokens[$at][1] : $tokens[$at];
    }
    $text = preg_replace('/\s+/', ' ', $text);

    return [
        'name'       => $tokens[$name][1],
        'line'       => $tokens[$index][2],
        'parameters' => $parameters,
        'return'     => $return,
        'close'      => $close,
        'text'       => str_replace(['( ', ' )'], ['(', ')'], $text),
        'body'       => $tokens[$after] === '{' ? $after : null,
        'to'         => $tokens[$after] === '{'
            ? closingIndex($tokens, $after, '}')
            : $after,
    ];
}

// Returns what the body that opens at $open has of its own, outside the
// closures and classes declared in it: [a `return <value>;`, a `return;`,
// a `throw`].
function readReturns(array $tokens, int $open): array
{
    $close = closingIndex($tokens, $open, '}');
    $found = [false, false, false];

    for ($index = $open + 1; $index < $close; $index++) {
        $token = $tokens[$index];
        if (isToken($token, T_FUNCTION, ...CLASS_KINDS)) {
            while (!in_array($tokens[$index], ['{', ';'], true)) {
                $index++;
            }
            $index = closingIndex($tokens, $index, '}');
        } else if (isToken($token, T_RETURN)) {
            $isBare = $tokens[nextIndex($tokens, $index)] === ';';
            $found[$isBare ? 1 : 0] = true;
        } else if (isToken($token, T_THROW)) {
            $found[2] = true;
        }
    }

    return $found;
}

// Returns the traits a class body uses, its methods and its properties.
function readMembers(array $tokens, array $class): array
{
    $members = ['traits' => [], 'methods' => [], 'properties' => []];

    for ($index = $class['from'] + 1; $index < $class['to']; $index++) {
        $token = $tokens[$index];
        if ($token === '{' || isToken($token, ...BRACES)) {
            $index = closingIndex($tokens, $index, '}');
        } else if (isToken($token, T_USE)) {
            while (!in_array($tokens[++$index], [';', '{'], true)) {
                if (isToken($tokens[$index], ...NAME_KINDS)) {
                    $members['traits'][] = shortName($tokens[$index][1]);
                }
            }
            $index--;
        } else if (isToken($token, T_FUNCTION)) {
            $method = readMethod($tokens, $index);
            $members['methods'][] = $method;
            $index = $method['to'];
        } else if (isToken($token, T_VARIABLE)) {
            $from = $index;
            $stops = [';', '{', '}', ','];
            while (!in_array($tokens[$from - 1], $stops, true) &&
                !isToken($tokens[$from - 1], ...MODIFIERS)) {
                $from--;
            }
            $next = $tokens[nextIndex($tokens, $index)];
            $members['properties'][] = [
                'name'       => $token[1],
                'line'       => $token[2],
                'index'      => $index,
                'type'       => typeText($tokens, $from, $index - 1),
                'hasDefault' => $next === '=',
            ];
            while (!in_array($tokens[$index], [';', '{'], true)) {
                $index++;
            }
            $index--;
        }
    }

    return $members;
}

// Returns the tokens of a file, its declarations with their members and
// whether it declares a namespace; null when PHP cannot parse it.
function readSource(string $path): ?array
{
    $source = file_get_contents($path);
    if (!preg_match('/\b(class|interface|trait|enum)\b/i', $source)) {
        return null;
    }

    try {
        $tokens = token_get_all($source, TOKEN_PARSE);
    } catch (ParseError $error) {
        fwrite(STDERR, $path.': not parsed, '.$error->getMessage()."\n");

        return null;
    }

    $classes = [];
    $isNamespaced = false;
    foreach ($tokens as $token) {
        $isNamespaced = $isNamespaced || isToken($token, T_NAMESPACE);
    }
    foreach (readClasses($tokens) as $class) {
        $classes[] = $class + readMembers($tokens, $class);
    }

    return [$tokens, $classes, $isNamespaced];
}

// Returns the type as it is written in a file: a class name of the
// global namespace gets a leading backslash in a namespaced file.
function writtenType(string $type, bool $isNamespaced): string
{
    $name = ltrim($type, '?');
    if (!$isNamespaced || in_array(strtolower($name), BUILT_IN, true) ||
        str_contains($name, '|')) {
        return $type;
    }

    return str_replace($name, '\\'.$name, $type);
}

// Returns what a declaration has to change for the families it is in:
// [line, kind, text, token index or null, text to write or null].
function readFindings(
    array $tokens,
    array $class,
    array $families,
    bool $isNamespaced
): array
{
    $findings = [];
    $owner = $class['name'];

    foreach ($families as $family) {
        [, $methods, $properties] = FAMILIES[$family];

        foreach ($class['methods'] as $method) {
            $rule = $methods[$method['name']] ?? null;
            if ($rule === null) {
                continue;
            }
            [$types, $return] = $rule;
            $written = writtenType($return, $isNamespaced);
            $line = $method['line'];
            $name = $owner.'::'.$method['name'].'()';
            [$isValue, $isBare, $isThrow] = $method['body'] === null
                ? [true, false, false]
                : readReturns($tokens, $method['body']);
            $isEmpty = $isBare || (!$isValue && !$isThrow);

            foreach ($method['parameters'] as $position => $declared) {
                $parent = $types[$position] ?? null;
                if ($declared !== '' && $parent !== null &&
                    !isParameterAccepted($declared, $parent)) {
                    $findings[] = [
                        $line,
                        'by hand',
                        $name.': parameter '.
                        ($position + 1).' declares '.$declared.
                        ', the framework declares '.$parent.': `'.
                        $method['text'].'`',
                        null,
                        null,
                    ];
                }
            }

            if ($return === 'void' && $isValue) {
                $findings[] = [
                    $line,
                    'by hand',
                    $name.' returns a value and the framework declares '.
                    '`: void`: `'.$method['text'].'`',
                    null,
                    null,
                ];
            } else if ($return !== 'void' && $isEmpty) {
                $findings[] = [
                    $line,
                    'by hand',
                    $name.' has a `return;` or no `return`, and the '.
                    'framework declares `: '.$return.'`: return a value '.
                    'and declare the type: `'.$method['text'].'`',
                    null,
                    null,
                ];
            } else if ($method['return'] === '') {
                $findings[] = [
                    $line,
                    'return',
                    $owner.': `'.$method['text'].'` -> `'.$method['text'].
                    ': '.$written.'`',
                    $method['close'],
                    '): '.$written,
                ];
            } else if (!isReturnAccepted($method['return'], $return)) {
                $findings[] = [
                    $line,
                    'by hand',
                    $name.' declares `: '.
                    $method['return'].'`, the framework declares `: '.
                    $return.'`: `'.$method['text'].'`',
                    null,
                    null,
                ];
            }
        }

        foreach ($class['properties'] as $property) {
            $type = $properties[$property['name']] ?? null;
            if ($type === null) {
                continue;
            }
            $written = writtenType($type, $isNamespaced);
            $isNullable = str_starts_with($type, '?');
            $default = $isNullable && !$property['hasDefault']
                ? ' = null'
                : '';

            if ($property['type'] === '') {
                $findings[] = [
                    $property['line'],
                    'property',
                    $owner.': `'.$property['name'].'` -> `'.$written.' '.
                    $property['name'].$default.'`',
                    $property['index'],
                    $written.' '.$property['name'].$default,
                ];
            } else if (typeMembers($property['type']) !== typeMembers($type)) {
                $findings[] = [
                    $property['line'],
                    'by hand',
                    $owner.'::'.$property['name'].' declares '.
                    $property['type'].', the framework declares '.$type,
                    null,
                    null,
                ];
            }
        }
    }

    return $findings;
}

error_reporting(E_ALL & ~E_DEPRECATED);

$arguments = array_slice($argv, 1);
$isWrite = in_array('--write', $arguments, true);
$isVendor = in_array('--vendor', $arguments, true);
$files = [];

foreach (array_diff($arguments, ['--write', '--vendor']) as $argument) {
    $files = array_merge($files, findFiles($argument));
}

// Where the families of a name come from, by short name: a class has
// the families of what it extends and implements, a trait has the
// families of the classes that use it.
$sources = [];
$declared = [];
$paths = [];
foreach ($files as $path) {
    foreach (readSource($path)[1] ?? [] as $class) {
        $name = $class['name'];
        $declared[$name] = true;
        $paths[$path] = true;
        foreach ($class['parents'] as $parent) {
            $sources[$name][$parent] = true;
        }
        foreach ($class['traits'] as $trait) {
            $sources[$trait][$name] = true;
        }
    }
}

$familiesOf = [];
foreach (FAMILIES as $family => [$roots]) {
    foreach ($roots as $root) {
        $familiesOf[$root][$family] = true;
    }
}

do {
    $isGrown = false;
    foreach ($sources as $name => $from) {
        foreach (array_keys($from) as $source) {
            $new = array_diff_key(
                $familiesOf[$source] ?? [],
                $familiesOf[$name] ?? []
            );
            $familiesOf[$name] = ($familiesOf[$name] ?? []) + $new;
            $isGrown = $isGrown || $new;
        }
    }
} while ($isGrown);

$total = ['return' => 0, 'property' => 0, 'by hand' => 0, 'files' => 0];
$unknown = [];

foreach ($files as $path) {
    $isVendorFile = str_contains('/'.$path, '/vendor/');
    if (!isset($paths[$path]) || ($isVendorFile && !$isVendor)) {
        continue;
    }

    [$tokens, $classes, $isNamespaced] = readSource($path);
    $isChanged = false;

    foreach ($classes as $class) {
        foreach ($class['extends'] as $parent) {
            $isKnown = isset($declared[$parent]) ||
                isset($familiesOf[$parent]) || class_exists($parent, false);
            if (!$isKnown && preg_match(
                '/(Plugin|Field|Proxy|Object|User)$/',
                $parent
            )) {
                $unknown[$parent] = true;
            }
        }

        $families = array_keys($familiesOf[$class['name']] ?? []);
        $isRoot = false;
        foreach (FAMILIES as [$roots]) {
            $isRoot = $isRoot || in_array($class['name'], $roots, true);
        }
        if ($isRoot || !$families) {
            continue;
        }

        $findings = readFindings($tokens, $class, $families, $isNamespaced);
        usort($findings, fn (array $one, array $two) => $one[0] <=> $two[0]);
        foreach ($findings as [$line, $kind, $text, $index, $new]) {
            if ($isWrite && $index !== null) {
                $tokens[$index] = $new;
                $isChanged = true;
                $kind .= ', rewritten';
            }
            $total[explode(',', $kind)[0]]++;
            printf("%s:%d: [%s] %s\n", $path, $line, $kind, $text);
        }
    }

    if ($isChanged) {
        $source = '';
        foreach ($tokens as $token) {
            $source .= is_array($token) ? $token[1] : $token;
        }
        file_put_contents($path, $source);
        $total['files']++;
    }
}

if ($unknown) {
    ksort($unknown);
    echo "\nNot checked, the parent class is not in the directories: ",
        implode(', ', array_keys($unknown)), "\n";
}

printf(
    "\n%d return types and %d property types %s, %d by hand%s\n",
    $total['return'],
    $total['property'],
    $isWrite ? 'added' : 'to add (--write adds them)',
    $total['by hand'],
    $isWrite ? ', '.$total['files'].' files rewritten' : ''
);
exit($total['by hand'] || (!$isWrite && $total['return'] + $total['property'])
    ? 1
    : 0);

Run it on the directories that hold classes, then let it add the types:

php native-type-overrides.php .
php native-type-overrides.php --write .
plugins/Jimbo/JimboObject.php:74: [return] JimboObject: `public function getSettings()` -> `public function getSettings(): array`
plugins/Contents/ContentsPlugin.php:34: [property] ContentsPlugin: `$object` -> `?IDataAccessObject $object = null`
tests/Helper/MockPlainSystemPlugin.php:55: [by hand] MockPlainSystemPlugin::onDisplayMain() has a `return;` or no `return`, and the framework declares `: ?bool`: return a value and declare the type: `public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array())`

2 return types and 1 property types to add (--write adds them), 1 by hand

Notes:

  • The script reads the files and loads no class. It follows extends, implements and the use of a trait by the short name of a class, so two classes with one name in different namespaces count as one.
  • A directory named vendor is read to find the parents and is not reported. --vendor reports it too, to see what a package has to change.
  • Not checked, the parent class is not in the directories: X names a parent the script did not find. Give it the directory that holds X, such as the plugins of the project or vendor.
  • [return] and [property] are what --write changes: it adds the return type after the parameters, and the type in front of the property; $object also gets = null when it has no default. In a file with a namespace it writes \Response and \IDataAccessObject. It changes nothing else in the file.
  • [by hand] is never rewritten: a return <value>; in onInit() or install(), a method that has to return a value and has a return; or no return (a method whose body only throws is not reported), a return type or a parameter type the framework type does not include, and a property declared with another type.
  • An added return type checks what the method returns. Read each rewritten method once: one that returns a value of another type, on some path, throws a TypeError when that path runs.
  • The exit code is 1 while something is left to change.

Run php -l on the rewritten files, the class-load check and the tests after --write.

What to do

  1. Add the return type of the table to each override. Remove the value from a return in onInit() and install(). Add return null; where a method that now declares ?bool or mixed ends without a value.
  2. For $object, remove the redeclaration and keep the class of the object in the docblock of the class, or repeat the type exactly: protected ?IDataAccessObject $object = null; with @var UsersObject above it.
  3. For $requiredFields, declare protected array $requiredFields, or remove the redeclaration and set the property in the constructor.
  4. In a class that extends PermissionsException and declares a constructor, call parent::__construct(), or declare protected $code = 403; in that class.
  5. Pass a string as the name to DefaultUser::get() and set(), and an array to getUrlRules(), addUrlRule() and callHandlerMethod().

Before:

<?php

class OrdersPlugin extends DisplayPlugin
{
    /** @var OrdersObject */
    protected $object;

    public function onInit()
    {
        $this->core->includeCss('orders.css');

        return parent::onInit();
    }
}

class OrdersObject extends SystemObject
{
    public function getSettings()
    {
        return $this->getAssoc('SELECT name, value FROM orders_settings');
    }
}

class OrdersSystemPlugin extends ApiPlugin
{
    public function install()
    {
        return true;
    }

    public function onDisplayMain(
        Response &$response,
        $storeName = false,
        $pluginName = false,
        $params = array()
    )
    {
        if (!$storeName) {
            return;
        }

        $store = $this->createStoreInstance($storeName, $params);
        $store->onRequest($response);
    }
}

class OrdersUser extends DefaultUser
{
    protected $requiredFields = ['auth', 'auth_id', 'id_shop'];

    public function getID()
    {
        return (int) parent::getID();
    }
}

class OrderStatusField extends SelectField
{
    public function getInputType()
    {
        return 'hidden';
    }
}

abstract class ShopApiProxy extends core\store\proxy\OpenApiProxy
{
    public function insert(array $values)
    {
        $values['source'] = 'shop';

        return parent::insert($values);
    }
}

After:

<?php

/**
 * @property OrdersObject $object
 */
class OrdersPlugin extends DisplayPlugin
{
    public function onInit(): void
    {
        $this->core->includeCss('orders.css');

        parent::onInit();
    }
}

class OrdersObject extends SystemObject
{
    public function getSettings(): array
    {
        return $this->getAssoc('SELECT name, value FROM orders_settings');
    }
}

class OrdersSystemPlugin extends ApiPlugin
{
    public function install(): void
    {
    }

    public function onDisplayMain(
        Response &$response,
        $storeName = false,
        $pluginName = false,
        $params = array()
    ): ?bool
    {
        if (!$storeName) {
            return null;
        }

        $store = $this->createStoreInstance($storeName, $params);
        $store->onRequest($response);

        return true;
    }
}

class OrdersUser extends DefaultUser
{
    protected array $requiredFields = ['auth', 'auth_id', 'id_shop'];

    public function getID(): int
    {
        return (int) parent::getID();
    }
}

class OrderStatusField extends SelectField
{
    public function getInputType(): string
    {
        return 'hidden';
    }
}

abstract class ShopApiProxy extends core\store\proxy\OpenApiProxy
{
    public function insert(array $values): mixed
    {
        $values['source'] = 'shop';

        return parent::insert($values);
    }
}

OrdersUser::getID() declares int: an override may declare any type that is part of the framework type, and every type but void is part of mixed.

Checklist per repository

The lists below are what the script reports for the repositories of the shared plugins and packages, as they were cloned on one machine on 2026-10-10. A clone can be behind its repository, and a repository that was not cloned there is not listed: the script, run in the repository, is the authority, and the lists are a start. Each line is file:line in the repository. All but the lines marked "by hand" are what --write does.

Order of the work:

  1. The shared plugins and packages first. A return type is valid with the release before this one too, so a plugin that adds the return types, and removes the redeclaration of $object instead of typing it, works with both releases and can be released before the framework.
  2. The framework.
  3. The projects, each with its update of the framework.

The test helpers of festi-framework-theme (tests/Helper) are loaded by the tests of the theme package and by the tests of a project theme that extend Festi\Theme\Tests\ThemeTestCase. Those tests stop with the first error of the table above from the moment they run with this release, until the theme package has the four changes listed for it. The tests of the framework do not load these helpers.

The shared plugins and packages:

FestiCore/php_festi_cli

Read at commit 46bbe2d of 2026-10-07, branch v7.2: 6 declarations, 2 of them by hand.

  • public function getResponseModel($call, $regs = array()) becomes public function getResponseModel($call, $regs = array()): Response:
  • tests/Resources/plugins/CliSystemPluginMock/CliSystemPluginMockPlugin.php:24
  • tests/Resources/plugins/System/SystemPlugin.php:14
  • public function install() becomes public function install(): void:
  • tests/Resources/plugins/CliSystemPluginMock/CliSystemPluginMockPlugin.php:39
  • tests/Resources/plugins/System/SystemPlugin.php:29
  • By hand: public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()) becomes public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()): ?bool, and the method gets a return with a value, which it does not have now:
  • tests/Resources/plugins/CliSystemPluginMock/CliSystemPluginMockPlugin.php:43
  • By hand: public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()) becomes public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()): ?bool, and the method gets a return with a value, which it does not have now:
  • tests/Resources/plugins/System/SystemPlugin.php:33

FestiPlugins/php_festi_plugin_acl

Read at commit dd50f79 of 2026-01-15: 1 declaration.

  • public function onInit() becomes public function onInit(): void:
  • ACLManagePlugin.php:32

FestiPlugins/php_festi_plugin_chat

Read at commit 4f05ae5 of 2026-09-16: 1 declaration.

  • public function onInit() becomes public function onInit(): void:
  • ChatPlugin.php:79

FestiPlugins/PHP_Festi_Plugin_Contents

Read at commit 6a7c935 of 2026-07-15: 1 declaration.

  • The property $object, declared without a type, becomes ?IDataAccessObject $object = null:
  • ContentsPlugin.php:34

FestiPlugins/php_festi_plugin_courses

Read at commit 82c45de of 2026-09-14: 1 declaration.

  • public function onInit() becomes public function onInit(): void:
  • CoursesPlugin.php:47

FestiCore/PHP_Festi_Plugin_GameWorld

Read at commit 09c12be of 2021-04-28: 5 declarations.

  • public function install() becomes public function install(): void:
  • GameWorldPlugin.php:17
  • public function onInit() becomes public function onInit(): void:
  • GameWorldPlugin.php:22
  • The property $requiredFields, declared without a type, becomes array $requiredFields:
  • world/platform/server/GameUser.php:9
  • public function get($name) becomes public function get($name): mixed:
  • world/platform/server/GameUser.php:27
  • public function getID() becomes public function getID(): mixed:
  • world/platform/server/GameUser.php:51

FestiSystemPlugins/PHP_Festi_Plugin_Jimbo

The Jimbo plugin. Some clones name the repository FestiCore/PHP_Festi_Plugin_Jimbo.

Read at commit dac2aba of 2026-10-06: 7 declarations.

  • public function install() becomes public function install(): void:
  • Domain/Install/InstallTrait.php:43
  • public function getResponseModel($call, $regs = array()) becomes public function getResponseModel($call, $regs = array()): \Response:
  • Domain/Request/RequestHandlerTrait.php:637
  • public function getUrlRules($search) becomes public function getUrlRules($search): array:
  • JimboObject.php:58
  • public function getSettings() becomes public function getSettings(): array:
  • JimboObject.php:74
  • public function addUrlRule($values) becomes public function addUrlRule($values): mixed:
  • JimboObject.php:128
  • public function onInit() becomes public function onInit(): void:
  • JimboPlugin.php:60
  • public function onDisplayMain(Response &$response, $table = false, $pluginName = false, $params = array()) becomes public function onDisplayMain(Response &$response, $table = false, $pluginName = false, $params = array()): ?bool:
  • JimboPlugin.php:168

FestiPlugins/php_festi_plugin_modules

Read at commit e4c48e9 of 2026-09-18: 1 declaration.

  • public function onInit() becomes public function onInit(): void:
  • ModulesPlugin.php:64

FestiCore/php_festiframework_compatibilitywrapper

Read at commit 0712734 of 2025-03-14, branch master: 5 declarations, 2 of them by hand.

  • public function getRole() becomes public function getRole(): mixed:
  • src/wordpress/WpUserBridge.php:23
  • public function get($name) becomes public function get($name): mixed:
  • src/wordpress/WpUserBridge.php:38
  • public function getID() becomes public function getID(): mixed:
  • src/wordpress/WpUserBridge.php:64
  • By hand: public function set($name, $value) becomes public function set($name, $value): mixed, and the method gets a return with a value, which it does not have now:
  • src/wordpress/WpUserBridge.php:56
  • By hand: public function getSessionData() becomes public function getSessionData(): array, and the method gets a return with a value, which it does not have now:
  • src/wordpress/WpUserBridge.php:60

FestiCore/php_festiframework_theme

Read at commit b5864cf of 2026-09-18, branch develop: 4 declarations, 1 of them by hand.

  • public function getResponseModel($call, $regs = array()) becomes public function getResponseModel($call, $regs = array()): \Response:
  • tests/Helper/MockPlainSystemPlugin.php:34
  • public function install() becomes public function install(): void:
  • tests/Helper/MockPlainSystemPlugin.php:50
  • public function insert(array $values) becomes public function insert(array $values): mixed:
  • tests/Helper/TestStoreProxy.php:58
  • By hand: public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()) becomes public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()): ?bool, and the method gets a return with a value, which it does not have now:
  • tests/Helper/MockPlainSystemPlugin.php:55

Read with the script, with classes of the kinds above and nothing to change: FestiCore/PHP_Festi_Plugin_GameWorldManager, FestiCore/PHP_Festi_Plugin_Queues, FestiCore/PHP_Festi_Plugin_RESTful, FestiCore/php_festi_plugin_users, FestiCore/php_festi_workbench, FestiCore/PHP_FestiServer, FestiCore/PHP_ObjectDB, FestiPlugins/PHP_Festi_Plugin_Activity_Logs, FestiPlugins/php_festi_plugin_ai_connector, FestiPlugins/php_festi_plugin_contactus, FestiPlugins/php_festi_plugin_dashboard, FestiPlugins/php_festi_plugin_festiide, FestiPlugins/PHP_Festi_Plugin_Mail, FestiPlugins/php_festi_plugin_mcpserver, FestiPlugins/php_festi_plugin_nativeappintegration, FestiPlugins/php_festi_plugin_pipeline, FestiPlugins/php_festi_plugin_reportconstructor, FestiPlugins/php_festi_plugin_scraper, FestiPlugins/PHP_Festi_Plugin_Subscribe, FestiPlugins/php_festi_plugin_tinymce, FestiPlugins/php_festiasync_plugin_rpc, festistorefields/php_festi_markdownfield, FestiSystemPlugins/php_festi_plugin_rpc. FestiCore/PHP_Festi_Cron has no class of these kinds. The tests of FestiPlugins/php_festi_plugin_mcpserver and of FestiSystemPlugins/php_festi_plugin_rpc use the Jimbo plugin as a submodule, which has to point at a commit with the changes of the plugin.

A project runs the script in its own repository: most of what it reports there is onInit() of the project plugins, which --write types. A project that keeps a copy of the Jimbo plugin in its tree takes the changes of the plugin for the copy too.

Adding a section

A merge request that breaks project code adds a section to this file:

  1. Add a ## section after the last change section and before this one, or a ### subsection of "Native types and strict_types" when the change is the type of a member.
  2. Use the four labels in this order: What changed, Who is affected, How to find it, What to do.
  3. Give real signatures, a command that finds the affected project code, and a before and after example that passes php -l.
  4. Say which part is not breaking in practice, if there is one.
  5. Add a row to the summary, and a row to the error table when the change has an error message of its own.