timefrontiers/php-multiform

Dynamic table entity class with runtime table resolution

Maintainers

Package info

github.com/timefrontiers/php-multiform

pkg:composer/timefrontiers/php-multiform

Transparency log

Statistics

Installs: 38

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.1 2026-08-20 05:46 UTC

This package is auto-updated.

Last update: 2026-08-20 05:47:12 UTC


README

Runtime-selected database/table records with prepared CRUD, magic property hydration, dirty tracking, structured errors, and page-number pagination.

Multiform is intended for dynamic/admin/multi-table application data. Use explicit repositories and reviewed SQL for locks, immutable financial rows, versioned transitions, exact affected-row invariants, and background workers.

Requirements

  • PHP 8.5+
  • timefrontiers/php-database-object 1.1.x
  • timefrontiers/php-sql-database 1.1.x
  • timefrontiers/php-pagination 1.x
  • timefrontiers/php-has-errors 1.x

Install the coordinated 1.1 line:

composer require timefrontiers/php-multiform:^1.1.1

Basic usage

Explicit connection injection is the safe default:

use TimeFrontiers\Database\Multiform;

$record = Multiform::from(
  'app_database',
  'ordinary_records',
  'id',
  $database
);

$record->name = 'Example';
$record->status = 'active';

if (!$record->save()) {
  $errors = $record->getErrors();
}

The constructor has the same argument order:

$record = new Multiform('app_database', 'ordinary_records', 'id', $database);

For legacy callers, connection resolution remains instance connection, static connection, then the global $database facade:

$record->setConnection($database);
Multiform::useConnection($database);

Do not rely on static/global connection replacement inside caller-owned transactions.

Properties and dirty tracking

Database columns are exposed through magic properties:

$record->fill([
  'name' => 'Updated name',
  'status' => 'active',
]);

$id = $record->getId();
$data = $record->toArray();

Rows returned from findById(), findBySql(), or MultiformQuery retain the exact facade used for the read and begin clean:

$record = Multiform::from('app_database', 'ordinary_records', 'id', $database)
  ->findById(10);

if ($record !== false) {
  assert($record->conn() === $database);
  assert($record->isDirty() === false);

  $record->name = 'Changed';
  $changes = $record->getDirty();
}

Strict read results

SQL Database 1.1 failures are distinct from successful empty results:

$query = Multiform::from('app_database', 'ordinary_records', 'id', $database)
  ->query()
  ->where('status', 'active');

$records = $query->get();
if ($records === false) {
  $errors = $query->getErrors();
} elseif ($records === []) {
  // Successful query with no records.
}

$count = $query->count();
if ($count === false) {
  $errors = $query->getErrors();
} elseif ($count === 0) {
  // Successful count with no matches.
}

getRaw() follows the same array|false contract. findAll() and countAll() forward these corrected result types.

first() remains false for both no row and failure. exists() remains boolean. Inspect errors on the retained builder when the distinction matters:

$query = Multiform::from('app_database', 'ordinary_records', 'id', $database)
  ->query()
  ->where('id', 10);

$record = $query->first();
if ($record === false && $query->hasErrors('first')) {
  // Database failure rather than a successful no-row result.
}

findById() records a user-facing not-found error only after a successful query with no row. Database failures import the safe diagnostics appended by that operation.

Fluent queries

Values remain prepared parameters. Identifiers and operators are validated before SQL is executed:

$records = Multiform::from('app_database', 'ordinary_records', 'id', $database)
  ->query()
  ->select(['id', 'name', 'status'])
  ->where('status', 'active')
  ->where('amount', '>=', 100)
  ->whereNotNull('name')
  ->whereIn('id', [1, 2, 3])
  ->orderBy('name', 'ASC')
  ->limit(25)
  ->offset(0)
  ->get();

Supported general operators are:

=  !=  <>  <  <=  >  >=  LIKE  NOT LIKE  <=>

select() accepts only * and plain or dotted identifiers. Ordering accepts only ASC or DESC. Negative limit/offset values and unsupported identifiers, operators, directions, or expressions throw InvalidArgumentException before execution.

Use whereNull()/whereNotNull() for SQL null checks. An explicit where('column', '=', null) remains a bound three-argument condition. Empty whereIn() matches nothing and empty whereNotIn() matches everything.

Custom SQL

Reviewed expression-heavy queries belong in findBySql():

$records = Multiform::from('app_database', 'ordinary_records', 'id', $database)
  ->findBySql(
    'SELECT status, COUNT(*) AS total
     FROM :database:.:table:
     WHERE status <> ?
     GROUP BY status
     ORDER BY :primary_key:',
    ['archived']
  );

Available placeholders are :database:/:db:, :table:/:tbl:, and :primary_key:/:pkey:. Multiform expands them to quoted identifiers. Do not put backticks around placeholders. Raw SQLDatabase methods do not expand these placeholders.

Writes and metadata

save() inserts when the primary key is null or ''; every other value, including 0, "0", and non-empty strings, routes to update. Use explicit repository SQL for preassigned-key inserts.

  • Create succeeds only after a successful execute result; insert ID is read afterward for numeric primary keys.
  • A successfully executed unchanged update may return true with zero affected rows.
  • Ordinary updates never put the primary key in the SET list.
  • Delete succeeds only when exactly one row is affected.

_created, _updated, and _author are synthesized only when those columns exist in the runtime schema. For an _author column, an explicitly assigned value wins, followed by $session->name/getName(). If no author can be resolved, the field is omitted and an _create error is retained; the database schema then determines whether the insert can succeed. Tables without an _author column never receive an author error.

Schema metadata is isolated by concrete wrapped connection identity through DatabaseObject 1.1 TableSchema. After runtime DDL, clear metadata explicitly:

use TimeFrontiers\Database\Schema\TableSchema;

TableSchema::clearCache('app_database', 'ordinary_records');

Pagination

paginate() counts matching rows and retrieves one page without replacing the builder's selected columns or stored limit/offset:

$query = Multiform::from('app_database', 'ordinary_records', 'id', $database)
  ->query()
  ->select(['id', 'name'])
  ->where('status', 'active')
  ->orderBy('id');

$result = $query->paginate(page: 2, per_page: 25);
if ($result === false) {
  $errors = $query->getErrors();
} else {
  foreach ($result['data'] as $record) {
    // Hydrated Multiform records.
  }
  $meta = $result['meta'];
}

When page values are omitted, they are read from $_GET then $_POST. Pagination clamps the page to at least 1 and per-page to 1–1000. A failed count or page read returns false and does not emit successful metadata.

Page-number pagination is for request/admin views. Use explicit keyset worker queries outside Multiform for background processing.

Errors

Multiform and MultiformQuery expose the ecosystem five-element error tuple:

[minimum rank, code, message, file, line]

Only connection errors appended during the current operation are imported; historical errors on a long-lived connection are not duplicated. Fallback messages contain no SQL values, bound parameters, credentials, or DSNs.

if (!$record->save()) {
  $errors = $record->getErrors();
  $message = $record->firstError('_create');
}

timefrontiers/php-instance-error remains an optional consumer for rank-based presentation and is not a production dependency of this package.

Transaction ownership

Multiform never begins, commits, rolls back, closes, changes, or upgrades the caller's connection:

$database->transaction(function (SQLDatabase $database): void {
  $record = Multiform::from(
    'app_database',
    'ordinary_records',
    'id',
    $database
  );
  $record->name = 'Example';

  if (!$record->save()) {
    // Translate checked persistence failure at the application boundary.
    throw new PersistenceException('The record could not be saved.');
  }
});
  • Returning false from a transaction callback does not request rollback.
  • Throw when a checked Multiform failure must abort the transaction.
  • SQL Database 1.1 owns rollback-only state and savepoints.
  • Commit failure can represent an uncertain outcome and must be reconciled by the caller.
  • External mail, SMS, provider, wallet, file, and queue actions stay outside transaction callbacks.

Testing

composer validate --strict
composer test-unit
composer test-pagination
composer test-mysqli
composer test-pdo
composer test-transaction
composer test
composer audit

Integration tests use TF_SQL_TEST_HOST, TF_SQL_TEST_PORT, TF_SQL_TEST_DATABASE, TF_SQL_TEST_USER, and TF_SQL_TEST_PASSWORD. The database name must contain test; tests use uniquely named disposable tables.

License

MIT