timefrontiers / php-multiform
Dynamic table entity class with runtime table resolution
Requires
- php: >=8.5
- timefrontiers/php-database-object: ^1.1
- timefrontiers/php-has-errors: ^1.0
- timefrontiers/php-pagination: ^1.0
- timefrontiers/php-sql-database: ^1.1
Requires (Dev)
- phpunit/phpunit: ^10.0
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-object1.1.xtimefrontiers/php-sql-database1.1.xtimefrontiers/php-pagination1.xtimefrontiers/php-has-errors1.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
SETlist. - 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