Skip to content
Muhammet Şafak edited this page May 24, 2026 · 1 revision

FAQ

Architecture

Why is the model not the connection?

The dependency chain — orm → database → dbal + query-builder — means each layer adds one concern. The model is the top of the chain; it does not own the connection, it composes one.

If you want raw query control, drop down: $model->getDatabase()->query(...). If you want connection control, drop further: $model->getDatabase()->getConnection()->getPDO(). You never lose access to the layer below.

Where does method X live?

Most methods are layered:

Method Layer
create, read, update, delete, save Model (this package)
transaction, affectedRows, getQueryLogs, query Database
select, where, join, orderBy, limit, raw QueryBuilder
asAssoc, asObject, asClass, rows, row DataMapper (DBAL)

When you call them on a model, __call forwards everything down. The chain re-wraps at every boundary so fluent calls stay rooted in the model. See Architecture.

Why is there no hasMany / belongsTo?

Relationships were an explicit non-goal. Use the query-builder's join() family directly — it composes cleanly with the model's __call forwarding:

$rows = $posts
    ->select('posts.*', 'users.name AS author_name')
    ->from('posts')
    ->innerJoin('users', 'posts.author_id = users.id')
    ->read()
    ->rows();

For eager loading, do two queries and a manual key/group:

$posts   = $postsModel->read()->rows();
$ids     = array_map(fn ($p) => $p->author_id, $posts);
$authors = $authorsModel->whereIn('id', $ids)->read()->rows();

$authorById = [];
foreach ($authors as $a) {
    $authorById[$a->id] = $a;
}

Two SELECTs > one JOIN > N+1 — and the code is dialect-portable.


Models

Why does my mutator leave getAttribute() empty?

You wrote the mutator like this:

public function setTitleAttribute($value): void
{
    $this->title = trim($value);   // ❌
}

From inside the class, $this->title = … creates a dynamic property and bypasses __set entirely. The value never reaches $attributes. PHP 8.2+ emits a "Creation of dynamic property" deprecation; a future PHP version will make this fatal.

The fix is $this->setAttribute('title', trim($value)). See Entities — this is the single biggest pitfall in the API.

Why does my model's table name come out as _posts (or similar nonsense)?

You're on v1, which shipped a broken Helper::camelCaseToSnakeCase(). Either set $schema explicitly or upgrade to v2. See Migration from v1 to v2.

How do I derive the schema name from a non-PascalCase class name?

Set $schema explicitly. Auto-derivation only handles PascalCase / camelCase short names. Anything else (already-snake_case, kebab-case, plural pluralisation rules, table prefixes, …) needs an explicit value.

Can I have a model without an entity class?

Yes. Leave $entity at its default (Entity::class), and read() will hydrate plain Entity instances. They behave the same as a subclass with no accessor/mutator methods.

class Tags extends \InitORM\ORM\Model
{
    protected string $schema = 'tags';
    // $entity defaults to Entity::class
}

foreach ((new Tags())->read()->rows() as $tag) {
    echo $tag->label;   // works fine
}

Does the model auto-pluralise the class name?

No. Posts → posts, but Person → person (not people). For irregular plurals, set $schema explicitly.

Can I override a CRUD method on a model?

Yes — they're plain public functions. The only thing to watch for is calling parent::method() to inherit the gate check, timestamp injection, and soft-delete predicates.

class Posts extends \InitORM\ORM\Model
{
    public function delete(?array $conditions = null, bool $purge = false): bool
    {
        // Refuse purge entirely; only soft-deletes are allowed through the model.
        return parent::delete($conditions, purge: false);
    }
}

Why does update(['id' => 5, 'title' => 'X']) not write the id?

By design — Model::update() lifts a non-empty $schemaId value out of $set into a WHERE clause, so you can never overwrite the primary key through the model. To explicitly update a row's id column (rare), use getDatabase()->update(...).


Entities

Why does __get return null for a column that should have a value?

Three possibilities:

  1. The column is not in $attributes because it was never written. Check $entity->getAttributes().
  2. You hydrated the entity via PDO::FETCH_CLASS but the column name in your SELECT doesn't match the property name. $entity->title reads $attributes['title']; if your SELECT aliased the column as t (SELECT title AS t), $attributes['t'] exists instead.
  3. An accessor method returns the wrong thing — e.g. it expected $value to be a string but received null.

Why is getOriginal() always empty?

You're on v1, which called syncOriginal() before fill() — the original snapshot was always taken when $attributes was empty. v2 fixed this; see Migration from v1 to v2.

Can I forbid creating a custom accessor / mutator?

PHP doesn't let interfaces declare magic methods, so you can't enforce this at the type level. But because Entity::__call provides a sensible default for the get{Column}Attribute / set{Column}Attribute family, subclasses without any explicit accessor / mutator methods work fine — there's no "incomplete entity" failure mode.

How do I serialise an entity to JSON?

$entity instanceof \JsonSerializable;     // false — Entity doesn't implement it

echo json_encode($entity->toArray());      // works, no custom interface needed

If you want json_encode($entity) to work directly:

class JsonEntity extends \InitORM\ORM\Entity implements \JsonSerializable
{
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

This is opt-in per subclass to avoid leaking values that an accessor might not transform.


CRUD

Why does read() return zero rows for soft-deleted rows?

By design — $useSoftDeletes = true adds deletedField IS NULL to every read. Use onlyDeleted() for soft-deleted rows specifically. See Soft Deletes.

How do I count rows?

$row = $model->selectCount('id', 'total')->read()->asAssoc()->row();
$total = (int) ($row['total'] ?? 0);

There is no count() helper on the model — selectCount('*', 'alias') + asAssoc()->row() is the idiomatic form, and it composes with any pending WHERE chain.

How do I read a single row by primary key?

$row = $model->read([], [$model->getSchemaId() => $id])->row();

If $row instanceof YourEntity, the row was found; otherwise row() returned null.

Why does my UPDATE affect every row in the table?

You called update([...]) without conditions and without a pending WHERE chain. The model does not refuse to issue an unbounded UPDATE — it trusts the caller. Always pass a primary key (lifted into a WHERE) or explicit $conditions:

$model->update(['id' => 5, 'col' => 'x']);        // safe
$model->update(['col' => 'x'], ['id' => 5]);      // safe
$model->update(['col' => 'x']);                    // affects all rows — usually a bug

For "all rows" updates, make the intent explicit by setting a condition that matches everything ('1' => '1' etc.) — it's verbose, and that's the point.

Why is insertId() returning a string?

Because PHP's \PDO::lastInsertId() returns a string. Cast it if you need an int:

$id = (int) $model->getDatabase()->insertId();

Connections

Why does (new MyModel()) throw DatabaseException about "no immutable instance"?

You haven't called DB::createImmutable($credentials) yet. Do it once at boot. See Getting Started and Multiple Connections.

Why does DB::createImmutable() throw the second time?

By design — silent reconfiguration of the application-wide connection is too easy to get wrong. To replace it explicitly:

DB::replaceImmutable($newDatabase);

For one-off secondary connections that don't touch the facade, use DB::connect($credentials) or new Database($credentials).

Why does each new Posts() open a new PDO connection (with $credentials set)?

Because the model resolves the connection in __construct() and there is no de-duplication at the model layer. For hot loops, hold one model instance:

// ❌ N connections
foreach ($jobs as $j) {
    (new EventsModel())->create(['payload' => $j]);
}

// ✅ 1 connection
$events = new EventsModel();
foreach ($jobs as $j) {
    $events->create(['payload' => $j]);
}

For per-request reuse across many call sites, build a registry or DI container.


Tests

Why do my tests share state across cases?

Either:

  • You reused a :memory: SQLite connection across setUp() (each test should get a fresh one), or
  • You called DB::createImmutable() once in setUpBeforeClass() (use replaceImmutable() per test instead).

See Testing.

Why does read() come back as stdClass in my test?

You instantiated an entity-less model ($entity = Entity::class) and then called ->asAssoc() or ->asObject() on the DataMapper, overriding FETCH_CLASS. Either:

  • Set $entity to your custom class and call ->rows() (no override), or
  • Use ->asAssoc()->rows() to get arrays explicitly.

Upgrading

What broke in v2?

Five things:

  • Helper::camelCaseToSnakeCase() was broken and is now correct. Schemas auto-derived in v1 will change.
  • Entity::__get now passes the stored value to the accessor (get{Column}Attribute($value)).
  • Mutators MUST use $this->setAttribute(...) — the previous $this->col = ... pattern was broken in PHP 8.2+.
  • Model::onlyDeleted() is now a one-shot flag-based scope; the v1 implementation produced a contradictory WHERE.
  • Entity::syncOriginal() is called after fill() so getOriginal() reflects construction-time data.

See Migration from v1 to v2 for diffs.


Still stuck?

  • Skim the Architecture page — most "why" questions have a structural answer.
  • Check the issue tracker.
  • Open a new issue with a minimal reproducible example.

Clone this wiki locally